iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }