main
js 268 lines 11.3 KB
Raw
1 // Browser-only Sui wallet helper for Portal x402 routes.
2 // Native clients should call /x402/prepare, sign the returned transaction with
3 // their own Sui runtime, then send the resulting payload as X-PAYMENT.
4 import { getWallets } from 'https://esm.sh/@wallet-standard/app@1.1.1';
5 import { Transaction } from 'https://esm.sh/@mysten/sui@2.17.0/transactions';
6
7 const walletAPI = getWallets();
8
9 export function getSuiWallets({ network = '' } = {}) {
10 network = String(network || '').trim();
11 return walletAPI.get().map((wallet, index) => {
12 const features = wallet.features || {};
13 const connect = features['standard:connect']?.connect?.bind(features['standard:connect']);
14 const sign = features['sui:signTransaction']?.signTransaction?.bind(features['sui:signTransaction']);
15 const signBlock = features['sui:signTransactionBlock']?.signTransactionBlock?.bind(features['sui:signTransactionBlock']);
16 if (!connect || (!sign && !signBlock)) {
17 return null;
18 }
19
20 const out = {
21 id: wallet.name || `wallet-${index}`,
22 name: wallet.name || `Wallet ${index + 1}`,
23 async accounts(chain = network) {
24 const connected = await connect();
25 return normalizeAccounts(connected?.accounts || wallet.accounts || [])
26 .filter((account) => !chain || !Array.isArray(account.chains) || account.chains.includes(chain));
27 },
28 async connect(chain = network, address = '') {
29 const accounts = await out.accounts(chain);
30 const normalizedAddress = String(address || '').trim().toLowerCase();
31 if (normalizedAddress) {
32 const account = accounts.find((candidate) => String(candidate.address || '').trim().toLowerCase() === normalizedAddress);
33 if (!account) {
34 throw new Error('Selected account is not available in the connected wallet');
35 }
36 return account;
37 }
38 if (accounts.length !== 1) {
39 if (accounts.length > 1) {
40 throw new Error('Select a Sui account before paying');
41 }
42 throw new Error('Connected wallet did not return an account');
43 }
44 return accounts[0];
45 },
46 async signTransaction(account, transaction, chain = network) {
47 if (sign) {
48 return sign({ account, chain, transaction });
49 }
50 return signBlock({ account, chain, transactionBlock: transaction });
51 },
52 };
53
54 const execute = features['sui:signAndExecuteTransaction']?.signAndExecuteTransaction?.bind(features['sui:signAndExecuteTransaction']);
55 const executeBlock = features['sui:signAndExecuteTransactionBlock']?.signAndExecuteTransactionBlock?.bind(features['sui:signAndExecuteTransactionBlock']);
56 if (execute || executeBlock) {
57 out.executeTransaction = async (account, transaction, chain = network) => {
58 if (execute) {
59 return execute({ account, chain, transaction, options: { showEffects: true } });
60 }
61 return executeBlock({ account, chain, transactionBlock: transaction, options: { showEffects: true } });
62 };
63 }
64 return out;
65 }).filter(Boolean);
66 }
67
68 export function onSuiWalletChange(callback) {
69 if (typeof walletAPI.on !== 'function') {
70 return () => {};
71 }
72 const offRegister = walletAPI.on('register', callback);
73 const offUnregister = walletAPI.on('unregister', callback);
74 return () => {
75 offRegister?.();
76 offUnregister?.();
77 };
78 }
79
80 export async function prepareX402Payment(options = {}) {
81 const fetcher = options.fetch || fetch.bind(globalThis);
82 const signal = options.signal;
83 const method = String(options.method || 'GET').trim().toUpperCase();
84 const path = String(options.path || '').trim();
85 if (!path || !path.startsWith('/')) {
86 throw new Error(path ? 'path must start with /' : 'path is required');
87 }
88
89 const wallet = options.wallet || getSuiWallets({ network: options.network })[0];
90 if (!wallet) {
91 throw new Error('No Sui wallet selected');
92 }
93
94 emitPaymentEvent(options, 'wallet.connect', 'Connecting wallet');
95 const network = String(options.network || '').trim();
96 const account = options.account || await wallet.connect(network, options.address);
97 if (!account?.address) {
98 throw new Error('Connected wallet did not return an account');
99 }
100 if (network && Array.isArray(account.chains) && !account.chains.includes(network)) {
101 throw new Error(`Connected account does not advertise ${network}`);
102 }
103
104 emitPaymentEvent(options, 'payment.prepare', 'Preparing USDC transaction', { method, path });
105 const prepareURL = options.preparePath || '/x402/prepare';
106 const prepareBody = { sender: account.address, method, path };
107 let prepared = await requestPrepare(fetcher, prepareURL, prepareBody, signal);
108 let paymentNetwork = String(prepared.paymentRequirements?.network || network).trim();
109 if (paymentNetwork && Array.isArray(account.chains) && !account.chains.includes(paymentNetwork)) {
110 throw new Error(`Connected account does not advertise ${paymentNetwork}`);
111 }
112
113 if (prepared.prepareTransaction?.transaction) {
114 if (!wallet.executeTransaction) {
115 throw new Error('Selected wallet cannot execute the USDC prepare transaction');
116 }
117 emitPaymentEvent(options, 'balance.prepare', 'Preparing object balance in wallet', { network: paymentNetwork });
118 const prepareResult = await wallet.executeTransaction(account, Transaction.from(fromBase64(prepared.prepareTransaction.transaction)), paymentNetwork);
119 const prepareStatus = prepareResult?.effects?.status?.status || prepareResult?.effects?.status;
120 if (prepareStatus && prepareStatus !== 'success') {
121 throw new Error(prepareResult.effects?.status?.error || 'USDC prepare transaction failed');
122 }
123
124 const pollAttempts = nonNegativeIntegerOption(options.preparePollAttempts, 20);
125 const pollIntervalMs = positiveIntegerOption(options.preparePollIntervalMs, 1000);
126 emitPaymentEvent(options, 'balance.wait', 'Waiting for prepared balance', {
127 attempts: pollAttempts,
128 intervalMs: pollIntervalMs,
129 });
130 for (let attempt = 0; attempt < pollAttempts && prepared.prepareTransaction?.transaction; attempt += 1) {
131 await delay(pollIntervalMs, signal);
132 prepared = await requestPrepare(fetcher, prepareURL, prepareBody, signal);
133 paymentNetwork = String(prepared.paymentRequirements?.network || paymentNetwork).trim();
134 emitPaymentEvent(options, 'balance.poll', 'Checking prepared balance', {
135 attempt: attempt + 1,
136 attempts: pollAttempts,
137 });
138 }
139 if (prepared.prepareTransaction?.transaction) {
140 throw new Error('Prepared USDC balance is not indexed yet');
141 }
142 }
143
144 if (!prepared.paymentTransaction?.transaction) {
145 throw new Error('Payment prepare response is missing a payment transaction');
146 }
147 emitPaymentEvent(options, 'payment.sign', 'Signing x402 payment', { network: paymentNetwork });
148 const signed = await wallet.signTransaction(account, Transaction.from(fromBase64(prepared.paymentTransaction.transaction)), paymentNetwork);
149 const signature = typeof signed?.signature === 'string' ? signed.signature : (Array.isArray(signed?.signatures) ? signed.signatures[0] : '');
150 const transaction = signed?.bytes || signed?.transactionBlockBytes || prepared.paymentTransaction.transaction;
151 const transactionBytes = transaction instanceof Uint8Array ? toBase64(transaction) : transaction;
152 if (!signature || !transactionBytes) {
153 throw new Error('Wallet did not return a signed payment transaction');
154 }
155 if (transactionBytes !== prepared.paymentTransaction.transaction) {
156 throw new Error('Wallet returned different payment transaction bytes');
157 }
158
159 const payload = {
160 x402Version: prepared.x402Version,
161 payload: {
162 signature,
163 transaction: transactionBytes,
164 },
165 accepted: prepared.paymentRequirements,
166 resource: prepared.resource,
167 };
168 return {
169 account,
170 prepared,
171 payload,
172 paymentHeader: toBase64(new TextEncoder().encode(JSON.stringify(payload))),
173 };
174 }
175
176 export async function x402Fetch(input, init = {}, options = {}) {
177 const request = input instanceof Request ? new Request(input, init) : new Request(new URL(String(input), globalThis.location.href).href, init);
178 const signal = options.signal || request.signal;
179 const paid = await prepareX402Payment({
180 ...options,
181 signal,
182 method: request.method,
183 path: options.path || new URL(request.url).pathname,
184 });
185
186 emitPaymentEvent(options, 'payment.settle', 'Settling payment');
187 const headers = new Headers(request.headers);
188 headers.set('X-PAYMENT', paid.paymentHeader);
189 return (options.fetch || fetch.bind(globalThis))(new Request(request, { headers, signal }));
190 }
191
192 async function requestPrepare(fetcher, prepareURL, prepareBody, signal) {
193 const response = await fetcher(prepareURL, {
194 method: 'POST',
195 headers: { 'Content-Type': 'application/json' },
196 signal,
197 body: JSON.stringify(prepareBody),
198 });
199 if (!response.ok) {
200 throw new Error(await response.text());
201 }
202 return response.json();
203 }
204
205 function emitPaymentEvent(options, type, message, data = {}) {
206 options.onEvent?.({ type, message, data });
207 options.onStatus?.(message);
208 }
209
210 function nonNegativeIntegerOption(value, fallback) {
211 const number = Number(value);
212 if (!Number.isFinite(number) || number < 0) {
213 return fallback;
214 }
215 return Math.floor(number);
216 }
217
218 function positiveIntegerOption(value, fallback) {
219 const number = Number(value);
220 if (!Number.isFinite(number) || number <= 0) {
221 return fallback;
222 }
223 return Math.floor(number);
224 }
225
226 function delay(ms, signal) {
227 if (signal?.aborted) {
228 return Promise.reject(abortError());
229 }
230 return new Promise((resolve, reject) => {
231 const timeout = setTimeout(resolve, ms);
232 signal?.addEventListener('abort', () => {
233 clearTimeout(timeout);
234 reject(abortError());
235 }, { once: true });
236 });
237 }
238
239 function abortError() {
240 if (typeof DOMException === 'function') {
241 return new DOMException('Aborted', 'AbortError');
242 }
243 const error = new Error('Aborted');
244 error.name = 'AbortError';
245 return error;
246 }
247
248 function normalizeAccounts(value) {
249 const accounts = Array.isArray(value) ? value : (value ? [value] : []);
250 return accounts.map((account) => {
251 if (typeof account === 'string') {
252 return { address: account };
253 }
254 return account && typeof account.address === 'string' ? account : null;
255 }).filter(Boolean);
256 }
257
258 function fromBase64(value) {
259 return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
260 }
261
262 function toBase64(bytes) {
263 let binary = '';
264 for (let i = 0; i < bytes.length; i += 0x8000) {
265 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
266 }
267 return btoa(binary);
268 }