| 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 | } |