wallet-core.js (16325B)
1 const encoder = new TextEncoder(); 2 3 export const API_BASE = "https://iuna.jhx.app/v1"; 4 export const STORAGE_KEY = "iuna.wallets.v2"; 5 export const LEGACY_STORAGE_KEY = "iuna.wallet.v1"; 6 export const MICRO_IUNA = 1_000_000n; 7 export const MAX_TRANSACTION_BODY_BYTES = 64 * 1024; 8 9 export function emptyWalletStore() { 10 return { version: 2, activeId: null, wallets: [] }; 11 } 12 13 export function normalizeWalletStore(value, legacyWallet = null) { 14 if (value?.version === 2 && Array.isArray(value.wallets)) { 15 const wallets = value.wallets.filter((wallet) => wallet 16 && wallet.id 17 && (/^[0-9a-f]{64}$/.test(wallet.publicKeyHex) || (wallet.type === "readonly" && typeof wallet.address === "string")) 18 && ["signing", "readonly"].includes(wallet.type) 19 && (wallet.type === "readonly" || wallet.record)); 20 const activeId = wallets.some((wallet) => wallet.id === value.activeId) ? value.activeId : wallets[0]?.id || null; 21 return { version: 2, activeId, wallets }; 22 } 23 if (legacyWallet?.address) { 24 return { 25 version: 2, 26 activeId: `wallet-${legacyWallet.address}`, 27 wallets: [{ 28 id: `wallet-${legacyWallet.address}`, 29 name: "My wallet", 30 type: "signing", 31 record: legacyWallet, 32 publicKeyHex: legacyWallet.address, 33 }], 34 }; 35 } 36 return emptyWalletStore(); 37 } 38 39 export function walletId(publicKeyHex) { 40 return `wallet-${publicKeyHex}`; 41 } 42 43 export function upsertWallet(store, wallet) { 44 const wallets = store.wallets.filter((item) => item.id !== wallet.id); 45 wallets.push(wallet); 46 return { version: 2, activeId: wallet.id, wallets }; 47 } 48 49 export function removeWallet(store, id) { 50 const wallets = store.wallets.filter((wallet) => wallet.id !== id); 51 return { version: 2, activeId: wallets[0]?.id || null, wallets }; 52 } 53 54 export function ensureTransactionBodySize(transaction) { 55 const bodyBytes = encoder.encode(JSON.stringify(transaction)).byteLength; 56 if (bodyBytes > MAX_TRANSACTION_BODY_BYTES) { 57 const inputCount = transaction.inputs?.length || 0; 58 const bodyKiB = Math.ceil(bodyBytes / 1024); 59 throw new Error(`Transaction is too large (${inputCount} inputs, ${bodyKiB} KiB; maximum 64 KiB). Try a smaller amount or consolidate this wallet’s UTXOs first.`); 60 } 61 return bodyBytes; 62 } 63 64 export function bytesToHex(bytes) { 65 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); 66 } 67 68 export function hexToBytes(hex) { 69 if (!/^[0-9a-f]*$/.test(hex) || hex.length % 2) throw new Error("Invalid hexadecimal value"); 70 return Uint8Array.from(hex.match(/.{2}/g) || [], (pair) => Number.parseInt(pair, 16)); 71 } 72 73 function base64url(bytes) { 74 let binary = ""; 75 bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); 76 return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); 77 } 78 79 function fromBase64url(value) { 80 const normalized = value.replaceAll("-", "+").replaceAll("_", "/"); 81 const binary = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4)); 82 return Uint8Array.from(binary, (character) => character.charCodeAt(0)); 83 } 84 85 export async function walletFromSeed(seedPhrase) { 86 const normalized = normalizeSeed(seedPhrase); 87 const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(`iuna-wallet-seed:${normalized}`))); 88 const pkcs8Prefix = hexToBytes("302e020100300506032b657004220420"); 89 const pkcs8 = new Uint8Array(pkcs8Prefix.length + digest.length); 90 pkcs8.set(pkcs8Prefix); 91 pkcs8.set(digest, pkcs8Prefix.length); 92 let privateKey; 93 try { 94 privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]); 95 } catch { 96 throw new Error("This browser does not support secure Ed25519 keys. Use a recent version of Safari, Chrome, Edge, or Firefox."); 97 } 98 const jwk = await crypto.subtle.exportKey("jwk", privateKey); 99 const publicKey = fromBase64url(jwk.x); 100 return { seedPhrase: normalized, privateKey, publicKey, publicKeyHex: bytesToHex(publicKey) }; 101 } 102 103 export function normalizeSeed(seed) { 104 return seed.trim().toLowerCase().split(/\s+/).join(" "); 105 } 106 107 export async function encryptWallet(seedPhrase, password, publicKeyHex) { 108 if (password.length < 10) throw new Error("Choose a password of at least 10 characters"); 109 const salt = crypto.getRandomValues(new Uint8Array(16)); 110 const iv = crypto.getRandomValues(new Uint8Array(12)); 111 const material = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]); 112 const key = await crypto.subtle.deriveKey( 113 { name: "PBKDF2", hash: "SHA-256", salt, iterations: 310_000 }, 114 material, 115 { name: "AES-GCM", length: 256 }, 116 false, 117 ["encrypt"], 118 ); 119 const plaintext = encoder.encode(JSON.stringify({ seed: normalizeSeed(seedPhrase) })); 120 const ciphertext = new Uint8Array(await crypto.subtle.encrypt( 121 { name: "AES-GCM", iv, additionalData: encoder.encode(publicKeyHex) }, 122 key, 123 plaintext, 124 )); 125 return { 126 version: 1, 127 address: publicKeyHex, 128 kdf: "PBKDF2-SHA256", 129 iterations: 310_000, 130 cipher: "AES-256-GCM", 131 salt: base64url(salt), 132 iv: base64url(iv), 133 ciphertext: base64url(ciphertext), 134 }; 135 } 136 137 export async function decryptWallet(record, password) { 138 try { 139 const material = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveKey"]); 140 const key = await crypto.subtle.deriveKey( 141 { name: "PBKDF2", hash: "SHA-256", salt: fromBase64url(record.salt), iterations: record.iterations }, 142 material, 143 { name: "AES-GCM", length: 256 }, 144 false, 145 ["decrypt"], 146 ); 147 const plaintext = await crypto.subtle.decrypt( 148 { name: "AES-GCM", iv: fromBase64url(record.iv), additionalData: encoder.encode(record.address) }, 149 key, 150 fromBase64url(record.ciphertext), 151 ); 152 const wallet = await walletFromSeed(JSON.parse(new TextDecoder().decode(plaintext)).seed); 153 if (wallet.publicKeyHex !== record.address) throw new Error("Address verification failed"); 154 return wallet; 155 } catch { 156 throw new Error("Incorrect password or damaged wallet"); 157 } 158 } 159 160 const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; 161 const BECH32M = 0x2bc830a3; 162 163 function polymod(values) { 164 const generators = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; 165 let checksum = 1; 166 for (const value of values) { 167 const top = checksum >>> 25; 168 checksum = ((checksum & 0x1ffffff) << 5) ^ value; 169 for (let index = 0; index < 5; index += 1) if ((top >>> index) & 1) checksum ^= generators[index]; 170 checksum >>>= 0; 171 } 172 return checksum >>> 0; 173 } 174 175 function hrpExpand(hrp) { 176 return [...hrp].map((c) => c.charCodeAt(0) >>> 5).concat(0, [...hrp].map((c) => c.charCodeAt(0) & 31)); 177 } 178 179 function convertBits(data, from, to, pad) { 180 let acc = 0; 181 let bits = 0; 182 const result = []; 183 const max = (1 << to) - 1; 184 for (const value of data) { 185 if (value >>> from) throw new Error("Invalid address data"); 186 acc = ((acc << from) | value) & ((1 << (from + to - 1)) - 1); 187 bits += from; 188 while (bits >= to) { bits -= to; result.push((acc >>> bits) & max); } 189 } 190 if (pad && bits) result.push((acc << (to - bits)) & max); 191 else if (!pad && (bits >= from || ((acc << (to - bits)) & max))) throw new Error("Invalid address padding"); 192 return result; 193 } 194 195 export function encodeAddress(publicKey, networkId = "iuna-mainnet-v1") { 196 const hrp = networkId.includes("testnet") || networkId.includes("e2e") ? "tiuna" : "iuna"; 197 const data = [0, ...convertBits(publicKey, 8, 5, true)]; 198 const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; 199 const mod = (polymod(values) ^ BECH32M) >>> 0; 200 const checksum = Array.from({ length: 6 }, (_, index) => (mod >>> (5 * (5 - index))) & 31); 201 return `${hrp}1${[...data, ...checksum].map((value) => CHARSET[value]).join("")}`; 202 } 203 204 export function decodeAddress(address, expectedHrp = "iuna") { 205 const decoded = decodeVersionedAddress(address, expectedHrp); 206 if (decoded.version !== 0) throw new Error("This operation requires a legacy address"); 207 return decoded.payloadHex; 208 } 209 210 export function decodeVersionedAddress(address, expectedHrp = "iuna") { 211 const normalized = address.trim().toLowerCase(); 212 if (address !== address.toLowerCase() && address !== address.toUpperCase()) throw new Error("Address mixes uppercase and lowercase characters"); 213 const separator = normalized.lastIndexOf("1"); 214 if (separator < 1 || separator + 7 > normalized.length || normalized.slice(0, separator) !== expectedHrp) throw new Error(`This is not a valid ${expectedHrp} address`); 215 const data = [...normalized.slice(separator + 1)].map((char) => { 216 const value = CHARSET.indexOf(char); 217 if (value < 0) throw new Error("Address contains an invalid character"); 218 return value; 219 }); 220 if (polymod([...hrpExpand(expectedHrp), ...data]) !== BECH32M) throw new Error("Address checksum is invalid"); 221 const payload = data.slice(0, -6); 222 const version = payload.shift(); 223 if (![0, 1].includes(version)) throw new Error("Unsupported address version"); 224 const key = Uint8Array.from(convertBits(payload, 5, 8, false)); 225 if (key.length !== 32) throw new Error("Invalid address key"); 226 return { version, payloadHex: bytesToHex(key) }; 227 } 228 229 function pushU32(target, value) { 230 const view = new DataView(new ArrayBuffer(4)); 231 view.setUint32(0, value, false); 232 target.push(...new Uint8Array(view.buffer)); 233 } 234 235 function pushU64(target, value) { 236 const view = new DataView(new ArrayBuffer(8)); 237 view.setBigUint64(0, BigInt(value), false); 238 target.push(...new Uint8Array(view.buffer)); 239 } 240 241 function pushBytes(target, value) { 242 pushU32(target, value.length); 243 target.push(...value); 244 } 245 246 export function transferSigningBytes(status, inputs, outputs, fee) { 247 const bytes = [...encoder.encode("IUNA-TX"), 0, Number(status.transaction_signing_format_version || 1)]; 248 pushBytes(bytes, encoder.encode(status.chain_id)); 249 pushBytes(bytes, hexToBytes(status.genesis_hash)); 250 bytes.push(1); 251 pushU32(bytes, inputs.length); 252 inputs.forEach((input) => { 253 pushBytes(bytes, hexToBytes(input.outpoint.txid)); 254 pushU32(bytes, input.outpoint.index); 255 pushBytes(bytes, hexToBytes(input.owner)); 256 }); 257 pushU32(bytes, outputs.length); 258 outputs.forEach((output) => { 259 pushBytes(bytes, hexToBytes(output.address)); 260 pushU64(bytes, output.amount); 261 }); 262 pushU64(bytes, fee); 263 return Uint8Array.from(bytes); 264 } 265 266 function legacyTransferPayload(inputs, outputs, fee) { 267 const canonicalInputs = inputs.map((input) => `${input.outpoint.txid}:${input.outpoint.index}:${input.owner}`).join("|"); 268 const canonicalOutputs = outputs.map((output) => `${output.address}:${output.amount}`).join("|"); 269 return `utxo-transfer:${canonicalInputs}:${canonicalOutputs}:${fee}`; 270 } 271 272 function compactLength(value) { 273 let n = BigInt(value); 274 let length = 1; 275 while (n >= 128n) { n >>= 7n; length += 1; } 276 return length; 277 } 278 279 function protocolIdSize(hex) { return 1 + hex.length / 2; } 280 281 export function estimateTransferSize(inputs, outputs, fee) { 282 return 1 + compactLength(inputs.length) 283 + inputs.reduce((sum, input) => sum + protocolIdSize(input.outpoint.txid) + compactLength(input.outpoint.index) + 33, 0) 284 + compactLength(outputs.length) 285 + outputs.reduce((sum, output) => sum + 33 + compactLength(output.amount), 0) 286 + compactLength(fee) + 65; 287 } 288 289 export function selectInputs(utxos, amount, feeRate, owner, recipient) { 290 const sorted = [...utxos].sort((a, b) => Number(BigInt(b.output.amount) - BigInt(a.output.amount))); 291 const selected = []; 292 let total = 0n; 293 let fee = 0n; 294 for (const utxo of sorted) { 295 selected.push({ outpoint: utxo.outpoint, owner }); 296 total += BigInt(utxo.output.amount); 297 for (let pass = 0; pass < 8; pass += 1) { 298 const provisionalChange = total - amount - fee; 299 const outputs = [{ address: recipient, amount }]; 300 if (provisionalChange > 0n) outputs.push({ address: owner, amount: provisionalChange }); 301 const nextFee = BigInt(estimateTransferSize(selected, outputs, fee)) * BigInt(feeRate); 302 if (nextFee === fee) break; 303 fee = nextFee; 304 } 305 if (total >= amount + fee) return { inputs: selected, total, fee }; 306 } 307 throw new Error("Insufficient spendable balance for the amount and network fee"); 308 } 309 310 export async function buildSignedTransfer({ wallet, status, utxos, recipientAddress, amount, feeRate }) { 311 if (!wallet?.privateKey) throw new Error("This watch-only wallet cannot sign transactions"); 312 const expectedHrp = status.chain_id.includes("testnet") || status.chain_id.includes("e2e") ? "tiuna" : "iuna"; 313 const recipient = decodeAddress(recipientAddress, expectedHrp); 314 const selectedFeeRate = BigInt(feeRate ?? status.default_fee_per_byte ?? 1); 315 if (selectedFeeRate < 1n) throw new Error("Fee rate must be at least 1 µIUNA per byte"); 316 const { inputs, total, fee } = selectInputs(utxos, amount, selectedFeeRate, wallet.publicKeyHex, recipient); 317 const outputs = [{ address: recipient, amount }]; 318 const change = total - amount - fee; 319 if (change > 0n) outputs.push({ address: wallet.publicKeyHex, amount: change }); 320 const activation = BigInt(status.transaction_signing_v1_activation_height || 1000); 321 const signingHeight = BigInt(status.height || 0) + 1n; 322 const signingPayload = signingHeight >= activation 323 ? transferSigningBytes(status, inputs, outputs, fee) 324 : encoder.encode(legacyTransferPayload(inputs, outputs, fee)); 325 const signature = bytesToHex(new Uint8Array(await crypto.subtle.sign("Ed25519", wallet.privateKey, signingPayload))); 326 const transaction = { 327 kind: "transfer", 328 inputs: inputs.map((input) => ({ ...input, signature })), 329 outputs: outputs.map((output) => ({ ...output, amount: Number(output.amount) })), 330 fee: Number(fee), 331 signature, 332 }; 333 ensureTransactionBodySize(transaction); 334 return { transaction, fee }; 335 } 336 337 export function parseIuna(value) { 338 const normalized = value.trim().replace(",", "."); 339 if (!/^\d+(\.\d{0,6})?$/.test(normalized)) throw new Error("Enter a valid amount (up to 6 decimal places)"); 340 const [whole, fraction = ""] = normalized.split("."); 341 return BigInt(whole) * MICRO_IUNA + BigInt(fraction.padEnd(6, "0")); 342 } 343 344 export function formatIuna(value, maximumFractionDigits = 6) { 345 const amount = BigInt(value || 0); 346 const whole = amount / MICRO_IUNA; 347 const fraction = (amount % MICRO_IUNA).toString().padStart(6, "0").slice(0, maximumFractionDigits).replace(/0+$/, ""); 348 return `${whole.toLocaleString("en-US")}${fraction ? `.${fraction}` : ""}`; 349 } 350 351 export function parseFeeRate(value) { 352 const normalized = String(value).trim(); 353 if (!/^\d+$/.test(normalized) || BigInt(normalized) < 1n) { 354 throw new Error("Fee rate must be a whole number of at least 1 µIUNA per byte"); 355 } 356 const feeRate = BigInt(normalized); 357 if (feeRate > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Fee rate is too large"); 358 return feeRate; 359 } 360 361 export async function api(path, options = {}) { 362 const controller = new AbortController(); 363 const timeout = window.setTimeout(() => controller.abort(), options.timeoutMs || 8_000); 364 const { timeoutMs: _timeoutMs, signal: callerSignal, ...requestOptions } = options; 365 if (callerSignal) callerSignal.addEventListener("abort", () => controller.abort(), { once: true }); 366 let response; 367 try { 368 response = await fetch(`${API_BASE}${path}`, { 369 ...requestOptions, 370 signal: controller.signal, 371 headers: { 372 ...(requestOptions.method && requestOptions.method !== "GET" ? { "content-type": "application/json" } : {}), 373 ...(requestOptions.headers || {}), 374 }, 375 }); 376 } catch (error) { 377 if (error.name === "AbortError") throw new Error(`No response from iuna after ${(_timeoutMs || 8_000) / 1000} seconds`); 378 throw new Error("Could not connect to the iuna endpoint"); 379 } finally { 380 window.clearTimeout(timeout); 381 } 382 let body; 383 try { body = await response.json(); } catch { body = {}; } 384 if (response.status === 413 && ["/transactions", "/transactions-v2"].includes(path) && requestOptions.method === "POST") { 385 throw new Error("Transaction is too large for the public endpoint. Try a smaller amount or consolidate this wallet’s UTXOs first."); 386 } 387 if (!response.ok) throw new Error(body.error || `Endpoint returned status ${response.status}`); 388 return body; 389 }