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/dist/seller.js ADDED
@@ -0,0 +1,136 @@
1
+ const USDC = {
2
+ // Base only, matching the wrapper. Anything else needs explicit asset + extra.
3
+ "base": { asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", caip2: "eip155:8453", name: "USD Coin", version: "2" }
4
+ };
5
+ function createX402Seller(cfg) {
6
+ if (!/^0x[0-9a-fA-F]{40}$/.test(cfg.payTo)) throw new Error("x402 seller: payTo must be a 20-byte address");
7
+ if (!/^[0-9]+$/.test(cfg.price) || BigInt(cfg.price) <= 0n) throw new Error("x402 seller: price must be a positive integer in atomic units");
8
+ const key = cfg.network.toLowerCase();
9
+ const known = USDC[key] ?? Object.values(USDC).find((u) => u.caip2 === key);
10
+ if (!known && !(cfg.asset && cfg.extra)) throw new Error(`x402 seller: unknown network '${cfg.network}' - pass asset and extra explicitly`);
11
+ if (!cfg.facilitator || !/^https?:\/\//.test(cfg.facilitator)) {
12
+ throw new Error(
13
+ "x402 seller: `facilitator` is required and must be an http(s) URL. There is no default - the public x402.org facilitator settles testnet only, so defaulting to it would make every mainnet payment verify and then fail to settle."
14
+ );
15
+ }
16
+ const facilitator = cfg.facilitator.replace(/\/$/, "");
17
+ const timeout = cfg.maxTimeoutSeconds ?? 600;
18
+ const requirements = {
19
+ scheme: "exact",
20
+ network: known?.caip2 ?? cfg.network,
21
+ amount: cfg.price,
22
+ asset: cfg.asset ?? known.asset,
23
+ payTo: cfg.payTo,
24
+ maxTimeoutSeconds: timeout,
25
+ extra: cfg.extra ?? { name: known.name, version: known.version }
26
+ };
27
+ if (!cfg.nonceStore && !cfg.acknowledgeEphemeralReplayGuard) {
28
+ throw new Error(
29
+ `x402 seller: network '${cfg.network}' needs a durable nonceStore. The default replay guard is in-memory and forgets every settled payment on restart, so a buyer could re-present one and get the resource again for free. Pass nonceStore (see createFileNonceStore) or acknowledgeEphemeralReplayGuard: true.`
30
+ );
31
+ }
32
+ const seenLocal = /* @__PURE__ */ new Map();
33
+ let lastPrune = 0;
34
+ const store = cfg.nonceStore ?? {
35
+ seen: async (n) => {
36
+ const now = Math.floor(Date.now() / 1e3);
37
+ if (now - lastPrune > 60) {
38
+ lastPrune = now;
39
+ for (const [k, exp2] of seenLocal) if (exp2 <= now) seenLocal.delete(k);
40
+ }
41
+ const exp = seenLocal.get(n.toLowerCase());
42
+ return exp !== void 0 && exp > now;
43
+ },
44
+ add: async (n, expiresAt) => {
45
+ seenLocal.set(n.toLowerCase(), expiresAt);
46
+ }
47
+ };
48
+ const post = async (path, body) => {
49
+ const r = await fetch(facilitator + path, {
50
+ method: "POST",
51
+ headers: { "content-type": "application/json" },
52
+ body: JSON.stringify(body),
53
+ signal: AbortSignal.timeout(45e3)
54
+ });
55
+ const text = await r.text();
56
+ try {
57
+ return JSON.parse(text);
58
+ } catch {
59
+ return { _raw: text, _status: r.status };
60
+ }
61
+ };
62
+ const challenge = (url) => new Response(
63
+ JSON.stringify({ error: "payment required", price: cfg.price, payTo: cfg.payTo }),
64
+ {
65
+ status: 402,
66
+ headers: {
67
+ "content-type": "application/json",
68
+ // v2 transport: protocol data rides in the header, the body is the app's own
69
+ "payment-required": JSON.stringify({
70
+ x402Version: 2,
71
+ error: "PAYMENT-SIGNATURE header is required",
72
+ resource: { url, description: cfg.description ?? "paid resource", mimeType: "application/json" },
73
+ accepts: [requirements]
74
+ })
75
+ }
76
+ }
77
+ );
78
+ return {
79
+ requirements,
80
+ /** Returns {response} when the caller must NOT serve the resource. */
81
+ async guard(request) {
82
+ const url = request.url;
83
+ const header = request.headers.get("payment-signature") ?? request.headers.get("x-payment");
84
+ if (!header) return { response: challenge(url), reason: "no payment presented" };
85
+ let payload;
86
+ try {
87
+ payload = JSON.parse(atob(header));
88
+ } catch {
89
+ return { response: challenge(url), reason: "malformed payment header" };
90
+ }
91
+ const nonce = payload?.payload?.authorization?.nonce;
92
+ if (typeof nonce !== "string") return { response: challenge(url), reason: "payment missing an authorization nonce" };
93
+ if (await store.seen(nonce)) return { response: challenge(url), reason: "authorization nonce already used" };
94
+ const v = await post("/verify", { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements });
95
+ if (v?.isValid !== true) {
96
+ return { response: challenge(url), reason: "facilitator rejected: " + (v?.invalidReason ?? JSON.stringify(v)) };
97
+ }
98
+ const attempts = Math.max(1, cfg.settleRetries ?? 3);
99
+ const backoff = cfg.settleBackoffMs ?? 1500;
100
+ let s = null;
101
+ for (let i = 0; i < attempts; i++) {
102
+ s = await post("/settle", { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements });
103
+ if (s?.success === true) break;
104
+ if (i < attempts - 1) await new Promise((r) => setTimeout(r, backoff * (i + 1)));
105
+ }
106
+ if (s?.success !== true) {
107
+ const reason = "settlement failed after " + attempts + " attempts: " + (s?.errorReason ?? JSON.stringify(s));
108
+ cfg.onSettleFailure?.({ reason, attempts, nonce });
109
+ return {
110
+ settlementFailed: true,
111
+ reason,
112
+ response: new Response(JSON.stringify({
113
+ error: "settlement_unavailable",
114
+ detail: "Your payment was valid. Settlement failed on our side. Retry with the SAME payment header.",
115
+ reason: s?.errorReason ?? null
116
+ }), {
117
+ status: 503,
118
+ headers: { "content-type": "application/json", "retry-after": "5" }
119
+ })
120
+ };
121
+ }
122
+ const expiresAt = Number(payload?.payload?.authorization?.validBefore ?? 0) || Math.floor(Date.now() / 1e3) + timeout;
123
+ await store.add(nonce, expiresAt);
124
+ const settlement = { transaction: s.transaction, payer: s.payer, network: s.network };
125
+ cfg.onSettled?.({ ...settlement, amount: cfg.price });
126
+ return { response: null, settlement };
127
+ },
128
+ /** Header a paid response should carry, so the buyer can read the receipt. */
129
+ receiptHeader(settlement) {
130
+ return { "payment-response": btoa(JSON.stringify({ success: true, ...settlement })) };
131
+ }
132
+ };
133
+ }
134
+ export {
135
+ createX402Seller
136
+ };
@@ -0,0 +1 @@
1
+ const S={base:{asset:"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",caip2:"eip155:8453",name:"USD Coin",version:"2"}};function T(e){if(!/^0x[0-9a-fA-F]{40}$/.test(e.payTo))throw new Error("x402 seller: payTo must be a 20-byte address");if(!/^[0-9]+$/.test(e.price)||BigInt(e.price)<=0n)throw new Error("x402 seller: price must be a positive integer in atomic units");const y=e.network.toLowerCase(),l=S[y]??Object.values(S).find(t=>t.caip2===y);if(!l&&!(e.asset&&e.extra))throw new Error(`x402 seller: unknown network '${e.network}' - pass asset and extra explicitly`);if(!e.facilitator||!/^https?:\/\//.test(e.facilitator))throw new Error("x402 seller: `facilitator` is required and must be an http(s) URL. There is no default - the public x402.org facilitator settles testnet only, so defaulting to it would make every mainnet payment verify and then fail to settle.");const b=e.facilitator.replace(/\/$/,""),w=e.maxTimeoutSeconds??600,c={scheme:"exact",network:l?.caip2??e.network,amount:e.price,asset:e.asset??l.asset,payTo:e.payTo,maxTimeoutSeconds:w,extra:e.extra??{name:l.name,version:l.version}};if(!e.nonceStore&&!e.acknowledgeEphemeralReplayGuard)throw new Error(`x402 seller: network '${e.network}' needs a durable nonceStore. The default replay guard is in-memory and forgets every settled payment on restart, so a buyer could re-present one and get the resource again for free. Pass nonceStore (see createFileNonceStore) or acknowledgeEphemeralReplayGuard: true.`);const u=new Map;let f=0;const h=e.nonceStore??{seen:async t=>{const n=Math.floor(Date.now()/1e3);if(n-f>60){f=n;for(const[r,i]of u)i<=n&&u.delete(r)}const a=u.get(t.toLowerCase());return a!==void 0&&a>n},add:async(t,n)=>{u.set(t.toLowerCase(),n)}},g=async(t,n)=>{const a=await fetch(b+t,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(n),signal:AbortSignal.timeout(45e3)}),r=await a.text();try{return JSON.parse(r)}catch{return{_raw:r,_status:a.status}}},p=t=>new Response(JSON.stringify({error:"payment required",price:e.price,payTo:e.payTo}),{status:402,headers:{"content-type":"application/json","payment-required":JSON.stringify({x402Version:2,error:"PAYMENT-SIGNATURE header is required",resource:{url:t,description:e.description??"paid resource",mimeType:"application/json"},accepts:[c]})}});return{requirements:c,async guard(t){const n=t.url,a=t.headers.get("payment-signature")??t.headers.get("x-payment");if(!a)return{response:p(n),reason:"no payment presented"};let r;try{r=JSON.parse(atob(a))}catch{return{response:p(n),reason:"malformed payment header"}}const i=r?.payload?.authorization?.nonce;if(typeof i!="string")return{response:p(n),reason:"payment missing an authorization nonce"};if(await h.seen(i))return{response:p(n),reason:"authorization nonce already used"};const m=await g("/verify",{x402Version:2,paymentPayload:r,paymentRequirements:c});if(m?.isValid!==!0)return{response:p(n),reason:"facilitator rejected: "+(m?.invalidReason??JSON.stringify(m))};const d=Math.max(1,e.settleRetries??3),k=e.settleBackoffMs??1500;let s=null;for(let o=0;o<d&&(s=await g("/settle",{x402Version:2,paymentPayload:r,paymentRequirements:c}),s?.success!==!0);o++)o<d-1&&await new Promise(v=>setTimeout(v,k*(o+1)));if(s?.success!==!0){const o="settlement failed after "+d+" attempts: "+(s?.errorReason??JSON.stringify(s));return e.onSettleFailure?.({reason:o,attempts:d,nonce:i}),{settlementFailed:!0,reason:o,response:new Response(JSON.stringify({error:"settlement_unavailable",detail:"Your payment was valid. Settlement failed on our side. Retry with the SAME payment header.",reason:s?.errorReason??null}),{status:503,headers:{"content-type":"application/json","retry-after":"5"}})}}const R=Number(r?.payload?.authorization?.validBefore??0)||Math.floor(Date.now()/1e3)+w;await h.add(i,R);const x={transaction:s.transaction,payer:s.payer,network:s.network};return e.onSettled?.({...x,amount:e.price}),{response:null,settlement:x}},receiptHeader(t){return{"payment-response":btoa(JSON.stringify({success:!0,...t}))}}}}export{T as createX402Seller};
@@ -0,0 +1,64 @@
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
+ import { type Authorization } from './x402.js';
21
+ export interface Quote {
22
+ /** CAIP-2 chain id, e.g. 'eip155:8453'. */
23
+ network: string;
24
+ /** Atomic units, as a decimal string. */
25
+ amount?: string;
26
+ maxAmountRequired?: string;
27
+ /** Who is paid - the studio's wallet. */
28
+ payTo: string;
29
+ /** The asset contract. USDC on Base by default. */
30
+ asset?: string;
31
+ /** How long the quote is good for, in seconds. */
32
+ maxTimeoutSeconds?: number;
33
+ /** EIP-712 domain fields. A wrong name or version signs something the contract rejects. */
34
+ extra?: {
35
+ name?: string;
36
+ version?: string;
37
+ };
38
+ }
39
+ export interface SignedPurchase {
40
+ authorization: Authorization;
41
+ /** 65 bytes, 0x-prefixed. */
42
+ signature: string;
43
+ /** The address that signed - hand this to the backend as playerAddress. */
44
+ playerAddress: string;
45
+ }
46
+ /** Derive a player's wallet address from their key, without signing anything. */
47
+ export declare function addressFor(privateKey: string): string;
48
+ /**
49
+ * Generate a fresh player wallet. Returns the key ONCE - store it encrypted, and give the
50
+ * player a way to back it up. There is no recovery path: whoever holds the key holds the
51
+ * funds, and losing it loses whatever the player put in.
52
+ */
53
+ export declare function createPlayerWallet(): {
54
+ privateKey: string;
55
+ address: string;
56
+ };
57
+ /**
58
+ * Sign one purchase.
59
+ *
60
+ * The authorization is bounded three ways: it names the exact recipient, it names the exact
61
+ * amount, and it expires. It carries a random 32-byte nonce that the asset contract redeems
62
+ * once - so even if the signature is captured in flight it can buy that one item, once.
63
+ */
64
+ export declare function signPurchase(quote: Quote, privateKey: string): SignedPurchase;
package/dist/signer.js ADDED
@@ -0,0 +1,61 @@
1
+ import { toHex, fromHex, __internals } from "./x402.js";
2
+ const { addressOf, digest, domainSep, makeNonce, signWith, toBig, CHAINS } = __internals;
3
+ function addressFor(privateKey) {
4
+ const d = toBig(fromHex(privateKey));
5
+ if (d === 0n || d >= __internals.N) throw new Error("signer: invalid key material");
6
+ return addressOf(d);
7
+ }
8
+ function createPlayerWallet() {
9
+ const b = new Uint8Array(32);
10
+ for (; ; ) {
11
+ crypto.getRandomValues(b);
12
+ const d = toBig(b);
13
+ if (d > 0n && d < __internals.N) return { privateKey: toHex(b), address: addressOf(d) };
14
+ }
15
+ }
16
+ function signPurchase(quote, privateKey) {
17
+ const d = toBig(fromHex(privateKey));
18
+ if (d === 0n || d >= __internals.N) throw new Error("signer: invalid key material");
19
+ const from = addressOf(d);
20
+ const value = quote.amount ?? quote.maxAmountRequired;
21
+ if (typeof value !== "string" || !/^[0-9]+$/.test(value) || BigInt(value) <= 0n) {
22
+ throw new Error("signer: quote must carry a positive integer amount in atomic units");
23
+ }
24
+ if (!/^0x[0-9a-fA-F]{40}$/.test(quote.payTo)) {
25
+ throw new Error("signer: quote.payTo must be a 20-byte address");
26
+ }
27
+ const chain = CHAINS[quote.network.toLowerCase()] ?? Object.values(CHAINS).find((c) => c.caip2 === quote.network);
28
+ if (!chain && !(quote.asset && quote.extra?.name && quote.extra?.version)) {
29
+ throw new Error(
30
+ `signer: unknown network '${quote.network}' - pass asset and extra (name, version) explicitly`
31
+ );
32
+ }
33
+ const asset = quote.asset ?? chain.asset;
34
+ const name = quote.extra?.name ?? chain.name;
35
+ const version = quote.extra?.version ?? chain.version;
36
+ const chainId = chain?.id ?? Number(quote.network.split(":")[1]);
37
+ if (!Number.isInteger(chainId) || chainId <= 0) {
38
+ throw new Error(`signer: cannot determine chain id from '${quote.network}'`);
39
+ }
40
+ const now = Math.floor(Date.now() / 1e3);
41
+ const n32 = new Uint8Array(32);
42
+ crypto.getRandomValues(n32);
43
+ const authorization = {
44
+ from,
45
+ to: quote.payTo,
46
+ value,
47
+ // Sixty seconds of slack: a player's clock is not the chain's, and a validAfter in the
48
+ // future makes the transfer revert.
49
+ validAfter: String(now - 60),
50
+ validBefore: String(now + (quote.maxTimeoutSeconds ?? 600)),
51
+ nonce: toHex(n32)
52
+ };
53
+ const dsep = domainSep(name, version, chainId, asset);
54
+ const signature = signWith(makeNonce(), digest(dsep, authorization), d);
55
+ return { authorization, signature, playerAddress: from };
56
+ }
57
+ export {
58
+ addressFor,
59
+ createPlayerWallet,
60
+ signPurchase
61
+ };
@@ -0,0 +1 @@
1
+ import{toHex as u,fromHex as y,__internals as i}from"./x402.js";const{addressOf:o,digest:k,domainSep:S,makeNonce:b,signWith:N,toBig:c,CHAINS:w}=i;function T(n){const r=c(y(n));if(r===0n||r>=i.N)throw new Error("signer: invalid key material");return o(r)}function z(){const n=new Uint8Array(32);for(;;){crypto.getRandomValues(n);const r=c(n);if(r>0n&&r<i.N)return{privateKey:u(n),address:o(r)}}}function R(n,r){const t=c(y(r));if(t===0n||t>=i.N)throw new Error("signer: invalid key material");const d=o(t),s=n.amount??n.maxAmountRequired;if(typeof s!="string"||!/^[0-9]+$/.test(s)||BigInt(s)<=0n)throw new Error("signer: quote must carry a positive integer amount in atomic units");if(!/^0x[0-9a-fA-F]{40}$/.test(n.payTo))throw new Error("signer: quote.payTo must be a 20-byte address");const e=w[n.network.toLowerCase()]??Object.values(w).find(A=>A.caip2===n.network);if(!e&&!(n.asset&&n.extra?.name&&n.extra?.version))throw new Error(`signer: unknown network '${n.network}' - pass asset and extra (name, version) explicitly`);const f=n.asset??e.asset,l=n.extra?.name??e.name,h=n.extra?.version??e.version,a=e?.id??Number(n.network.split(":")[1]);if(!Number.isInteger(a)||a<=0)throw new Error(`signer: cannot determine chain id from '${n.network}'`);const g=Math.floor(Date.now()/1e3),m=new Uint8Array(32);crypto.getRandomValues(m);const p={from:d,to:n.payTo,value:s,validAfter:String(g-60),validBefore:String(g+(n.maxTimeoutSeconds??600)),nonce:u(m)},x=S(l,h,a,f),v=N(b(),k(x,p),t);return{authorization:p,signature:v,playerAddress:d}}export{T as addressFor,z as createPlayerWallet,R as signPurchase};
@@ -0,0 +1,143 @@
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
+ import { type SellerConfig } from './seller.js';
27
+ import { type ProceedsFeeConfig } from './proceeds-fee.js';
28
+ import type { Authorization } from './x402.js';
29
+ export interface PurchaseAccepted {
30
+ itemId: string;
31
+ playerId: string;
32
+ playerAddress: string;
33
+ /** Atomic units of the asset - NOT a float. 1.50 USDC is '1500000'. */
34
+ amount: string;
35
+ }
36
+ export interface PurchaseSettled extends PurchaseAccepted {
37
+ /** On-chain transaction hash. The money has moved. */
38
+ transaction: string;
39
+ network: string;
40
+ }
41
+ export interface PurchaseDeclined {
42
+ itemId: string;
43
+ playerId: string;
44
+ /**
45
+ * Machine-readable. Switch on this, do not parse `message`.
46
+ *
47
+ * unknown_item - not in the catalog
48
+ * already_used - this authorization was already redeemed (replay)
49
+ * rejected - the facilitator refused the signature or the amount
50
+ * settlement_failed - VALID payment, our side could not complete it
51
+ * malformed - the client sent something we could not read
52
+ */
53
+ code: 'unknown_item' | 'already_used' | 'rejected' | 'settlement_failed' | 'malformed';
54
+ /** Human-readable detail for the studio's logs. Never shown to a player by us. */
55
+ message: string;
56
+ /**
57
+ * True when the player's money is NOT at risk and the same authorization may be retried
58
+ * unchanged. False means mint a fresh one - re-sending would risk paying twice.
59
+ */
60
+ retryable: boolean;
61
+ }
62
+ interface Events {
63
+ accepted: PurchaseAccepted;
64
+ settled: PurchaseSettled;
65
+ declined: PurchaseDeclined;
66
+ }
67
+ export interface StorefrontConfig extends Omit<SellerConfig, 'price' | 'description'> {
68
+ /**
69
+ * itemId -> price in ATOMIC units of the asset, as a decimal string. USDC has 6 decimals,
70
+ * so 1.50 is '1500000'. Strings, not numbers: a float cannot represent money exactly and
71
+ * this value ends up inside a signature.
72
+ */
73
+ catalog: Record<string, string>;
74
+ /**
75
+ * Diagnostics for the studio's own logger. Nothing is ever printed - if this is absent,
76
+ * problems are silent and purchases simply fail closed.
77
+ */
78
+ onDiagnostic?: (d: {
79
+ code: string;
80
+ message: string;
81
+ }) => void;
82
+ /**
83
+ * The network fee, taken out of PROCEEDS - the player is debited exactly the sticker
84
+ * price and nothing is added on top. Requires `proceedsKey`, the key for the wallet named
85
+ * in `payTo`, because moving money out of that wallet needs its own authorization.
86
+ * Omit this and no fee is charged.
87
+ */
88
+ surcharge?: Omit<ProceedsFeeConfig, 'network' | 'onDiagnostic'>;
89
+ }
90
+ export interface PurchaseRequest {
91
+ itemId: string;
92
+ /** The studio's own player identifier. Passed through untouched, echoed on every event. */
93
+ playerId: string;
94
+ /** The wallet the player funds. Must match the authorization's `from`. */
95
+ playerAddress: string;
96
+ /** The EIP-3009 authorization the client signed. */
97
+ authorization: Authorization;
98
+ /** Its 65-byte signature, 0x-prefixed. */
99
+ signature: string;
100
+ }
101
+ export declare function createStorefront(cfg: StorefrontConfig): {
102
+ /** Item ids this storefront will sell. */
103
+ readonly items: string[];
104
+ /** The network fee taken from proceeds: whether it is on, and what it has collected. */
105
+ fee: {
106
+ readonly enabled: boolean;
107
+ stats: () => Promise<{
108
+ enabled: boolean;
109
+ salesSinceLastSweep: string;
110
+ accrued: string;
111
+ collected: string;
112
+ held: string;
113
+ lost: string;
114
+ }>;
115
+ };
116
+ /**
117
+ * What the client must sign to buy `itemId`: price, recipient, chain and the EIP-712
118
+ * domain. Costs nothing and moves nothing.
119
+ */
120
+ quote(itemId: string): {
121
+ scheme: string;
122
+ network: string;
123
+ amount: string;
124
+ asset: string;
125
+ payTo: string;
126
+ maxTimeoutSeconds: number;
127
+ extra: {
128
+ name: string;
129
+ version: string;
130
+ };
131
+ itemId: string;
132
+ } | null;
133
+ on<K extends keyof Events>(name: K, handler: (e: Events[K]) => void): () => void;
134
+ /**
135
+ * Redeem a signed authorization for an item.
136
+ *
137
+ * Emits `accepted` as soon as the request is well-formed and the item is real, then
138
+ * `settled` or `declined`. The studio decides which one grants the item: `accepted` is
139
+ * optimistic and fast, `settled` means the money has actually moved on-chain.
140
+ */
141
+ purchase(req: PurchaseRequest): Promise<PurchaseSettled | PurchaseDeclined>;
142
+ };
143
+ export {};
@@ -0,0 +1,173 @@
1
+ import { createX402Seller } from "./seller.js";
2
+ import { createProceedsFee } from "./proceeds-fee.js";
3
+ function createStorefront(cfg) {
4
+ const items = Object.keys(cfg.catalog);
5
+ if (items.length === 0) throw new Error("storefront: catalog is empty");
6
+ for (const [id, price] of Object.entries(cfg.catalog)) {
7
+ if (!/^[0-9]+$/.test(price) || BigInt(price) <= 0n) {
8
+ throw new Error(`storefront: price for '${id}' must be a positive integer in atomic units, got '${price}'`);
9
+ }
10
+ }
11
+ const sellers = /* @__PURE__ */ new Map();
12
+ const sellerFor = (itemId) => {
13
+ let s = sellers.get(itemId);
14
+ if (!s) {
15
+ s = createX402Seller({ ...cfg, price: cfg.catalog[itemId], description: itemId });
16
+ sellers.set(itemId, s);
17
+ }
18
+ return s;
19
+ };
20
+ const fee = createProceedsFee({
21
+ ...cfg.surcharge,
22
+ network: cfg.network,
23
+ onDiagnostic: cfg.onDiagnostic
24
+ });
25
+ if (fee.enabled && fee.from.toLowerCase() !== cfg.payTo.toLowerCase()) {
26
+ throw new Error(
27
+ `storefront: surcharge.proceedsKey belongs to ${fee.from}, but payTo is ${cfg.payTo}. The fee is taken from proceeds, so the key must be for the wallet that receives them.`
28
+ );
29
+ }
30
+ const handlers = { accepted: [], settled: [], declined: [] };
31
+ const emit = (name, e) => {
32
+ for (const h of handlers[name]) {
33
+ try {
34
+ h(e);
35
+ } catch (err) {
36
+ cfg.onDiagnostic?.({
37
+ code: "handler_threw",
38
+ message: `${name} handler threw: ${err instanceof Error ? err.message : String(err)}`
39
+ });
40
+ }
41
+ }
42
+ };
43
+ const decline = (e) => {
44
+ emit("declined", e);
45
+ return e;
46
+ };
47
+ return {
48
+ /** Item ids this storefront will sell. */
49
+ get items() {
50
+ return [...items];
51
+ },
52
+ /** The network fee taken from proceeds: whether it is on, and what it has collected. */
53
+ fee: { get enabled() {
54
+ return fee.enabled;
55
+ }, stats: () => fee.stats() },
56
+ /**
57
+ * What the client must sign to buy `itemId`: price, recipient, chain and the EIP-712
58
+ * domain. Costs nothing and moves nothing.
59
+ */
60
+ quote(itemId) {
61
+ if (!(itemId in cfg.catalog)) return null;
62
+ return { itemId, ...sellerFor(itemId).requirements };
63
+ },
64
+ on(name, handler) {
65
+ handlers[name].push(handler);
66
+ return () => {
67
+ const i = handlers[name].indexOf(handler);
68
+ if (i >= 0) handlers[name].splice(i, 1);
69
+ };
70
+ },
71
+ /**
72
+ * Redeem a signed authorization for an item.
73
+ *
74
+ * Emits `accepted` as soon as the request is well-formed and the item is real, then
75
+ * `settled` or `declined`. The studio decides which one grants the item: `accepted` is
76
+ * optimistic and fast, `settled` means the money has actually moved on-chain.
77
+ */
78
+ async purchase(req) {
79
+ const { itemId, playerId, playerAddress, authorization, signature } = req;
80
+ const amount = cfg.catalog[itemId];
81
+ if (!amount) {
82
+ return decline({
83
+ itemId,
84
+ playerId,
85
+ code: "unknown_item",
86
+ message: `no such item '${itemId}'`,
87
+ retryable: false
88
+ });
89
+ }
90
+ if (typeof signature !== "string" || !/^0x[0-9a-fA-F]{130}$/.test(signature)) {
91
+ return decline({
92
+ itemId,
93
+ playerId,
94
+ code: "malformed",
95
+ message: "signature must be a 0x-prefixed 65-byte hex string",
96
+ retryable: false
97
+ });
98
+ }
99
+ if (authorization?.from?.toLowerCase() !== playerAddress.toLowerCase()) {
100
+ return decline({
101
+ itemId,
102
+ playerId,
103
+ code: "malformed",
104
+ message: "authorization.from does not match playerAddress",
105
+ retryable: false
106
+ });
107
+ }
108
+ if (authorization?.value !== amount) {
109
+ return decline({
110
+ itemId,
111
+ playerId,
112
+ code: "malformed",
113
+ message: `authorization is for ${authorization?.value}, item costs ${amount}`,
114
+ retryable: false
115
+ });
116
+ }
117
+ emit("accepted", { itemId, playerId, playerAddress, amount });
118
+ const payload = {
119
+ x402Version: 2,
120
+ scheme: "exact",
121
+ network: sellerFor(itemId).requirements.network,
122
+ payload: { authorization, signature }
123
+ };
124
+ const request = new Request(`https://storefront.invalid/${encodeURIComponent(itemId)}`, {
125
+ headers: { "payment-signature": btoa(JSON.stringify(payload)) }
126
+ });
127
+ let gate;
128
+ try {
129
+ gate = await sellerFor(itemId).guard(request);
130
+ } catch (err) {
131
+ const message = err instanceof Error ? err.message : String(err);
132
+ cfg.onDiagnostic?.({ code: "guard_threw", message });
133
+ return decline({ itemId, playerId, code: "settlement_failed", message, retryable: true });
134
+ }
135
+ if (gate.settlement) {
136
+ const e = {
137
+ itemId,
138
+ playerId,
139
+ playerAddress,
140
+ amount,
141
+ transaction: gate.settlement.transaction,
142
+ network: gate.settlement.network
143
+ };
144
+ emit("settled", e);
145
+ await fee.record(BigInt(amount));
146
+ return e;
147
+ }
148
+ if (gate.settlementFailed) {
149
+ return decline({
150
+ itemId,
151
+ playerId,
152
+ code: "settlement_failed",
153
+ message: gate.reason ?? "settlement failed",
154
+ retryable: true
155
+ });
156
+ }
157
+ const reason = gate.reason ?? "refused";
158
+ const alreadyUsed = reason.includes("nonce already used");
159
+ return decline({
160
+ itemId,
161
+ playerId,
162
+ code: alreadyUsed ? "already_used" : "rejected",
163
+ message: reason,
164
+ // A spent nonce will never become unspent, and a rejected signature will not become
165
+ // valid. Both need a fresh authorization, not a retry.
166
+ retryable: false
167
+ });
168
+ }
169
+ };
170
+ }
171
+ export {
172
+ createStorefront
173
+ };
@@ -0,0 +1 @@
1
+ import{createX402Seller as v}from"./seller.js";import{createProceedsFee as k}from"./proceeds-fee.js";function $(s){const p=Object.keys(s.catalog);if(p.length===0)throw new Error("storefront: catalog is empty");for(const[t,e]of Object.entries(s.catalog))if(!/^[0-9]+$/.test(e)||BigInt(e)<=0n)throw new Error(`storefront: price for '${t}' must be a positive integer in atomic units, got '${e}'`);const h=new Map,g=t=>{let e=h.get(t);return e||(e=v({...s,price:s.catalog[t],description:t}),h.set(t,e)),e},i=k({...s.surcharge,network:s.network,onDiagnostic:s.onDiagnostic});if(i.enabled&&i.from.toLowerCase()!==s.payTo.toLowerCase())throw new Error(`storefront: surcharge.proceedsKey belongs to ${i.from}, but payTo is ${s.payTo}. The fee is taken from proceeds, so the key must be for the wallet that receives them.`);const l={accepted:[],settled:[],declined:[]},f=(t,e)=>{for(const r of l[t])try{r(e)}catch(o){s.onDiagnostic?.({code:"handler_threw",message:`${t} handler threw: ${o instanceof Error?o.message:String(o)}`})}},n=t=>(f("declined",t),t);return{get items(){return[...p]},fee:{get enabled(){return i.enabled},stats:()=>i.stats()},quote(t){return t in s.catalog?{itemId:t,...g(t).requirements}:null},on(t,e){return l[t].push(e),()=>{const r=l[t].indexOf(e);r>=0&&l[t].splice(r,1)}},async purchase(t){const{itemId:e,playerId:r,playerAddress:o,authorization:u,signature:m}=t,c=s.catalog[e];if(!c)return n({itemId:e,playerId:r,code:"unknown_item",message:`no such item '${e}'`,retryable:!1});if(typeof m!="string"||!/^0x[0-9a-fA-F]{130}$/.test(m))return n({itemId:e,playerId:r,code:"malformed",message:"signature must be a 0x-prefixed 65-byte hex string",retryable:!1});if(u?.from?.toLowerCase()!==o.toLowerCase())return n({itemId:e,playerId:r,code:"malformed",message:"authorization.from does not match playerAddress",retryable:!1});if(u?.value!==c)return n({itemId:e,playerId:r,code:"malformed",message:`authorization is for ${u?.value}, item costs ${c}`,retryable:!1});f("accepted",{itemId:e,playerId:r,playerAddress:o,amount:c});const b={x402Version:2,scheme:"exact",network:g(e).requirements.network,payload:{authorization:u,signature:m}},x=new Request(`https://storefront.invalid/${encodeURIComponent(e)}`,{headers:{"payment-signature":btoa(JSON.stringify(b))}});let a;try{a=await g(e).guard(x)}catch(d){const w=d instanceof Error?d.message:String(d);return s.onDiagnostic?.({code:"guard_threw",message:w}),n({itemId:e,playerId:r,code:"settlement_failed",message:w,retryable:!0})}if(a.settlement){const d={itemId:e,playerId:r,playerAddress:o,amount:c,transaction:a.settlement.transaction,network:a.settlement.network};return f("settled",d),await i.record(BigInt(c)),d}if(a.settlementFailed)return n({itemId:e,playerId:r,code:"settlement_failed",message:a.reason??"settlement failed",retryable:!0});const y=a.reason??"refused",P=y.includes("nonce already used");return n({itemId:e,playerId:r,code:P?"already_used":"rejected",message:y,retryable:!1})}}}export{$ as createStorefront};