434 lines
12 KiB
JavaScript
434 lines
12 KiB
JavaScript
|
|
let receiveTokenListRequestId = 0;
|
||
|
|
|
||
|
|
function setPaymentMessage(message, mode = "") {
|
||
|
|
if (!paymentMessage) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
paymentMessage.textContent = message;
|
||
|
|
paymentMessage.classList.toggle("error", mode === "error");
|
||
|
|
paymentMessage.classList.toggle("success", mode === "success");
|
||
|
|
}
|
||
|
|
|
||
|
|
function receiveAddress() {
|
||
|
|
return activeWalletAddressShort || activeWalletAddressHex || "";
|
||
|
|
}
|
||
|
|
|
||
|
|
function paymentRequestUri() {
|
||
|
|
const address = receiveAddress();
|
||
|
|
if (!address) {
|
||
|
|
return "";
|
||
|
|
}
|
||
|
|
|
||
|
|
const params = new URLSearchParams();
|
||
|
|
const asset = paymentAssetSelect?.value || activeBaseCoin();
|
||
|
|
const amount = paymentAmountInput?.value?.trim() || "";
|
||
|
|
const label = paymentLabelInput?.value?.trim() || "";
|
||
|
|
|
||
|
|
if (asset) {
|
||
|
|
params.set("asset", asset);
|
||
|
|
}
|
||
|
|
if (amount) {
|
||
|
|
params.set("amount", amount);
|
||
|
|
}
|
||
|
|
if (label) {
|
||
|
|
params.set("label", label);
|
||
|
|
}
|
||
|
|
|
||
|
|
const query = params.toString();
|
||
|
|
return `contractless:${address}${query ? `?${query}` : ""}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrGaloisTables() {
|
||
|
|
const exp = new Array(512).fill(0);
|
||
|
|
const log = new Array(256).fill(0);
|
||
|
|
let value = 1;
|
||
|
|
for (let index = 0; index < 255; index += 1) {
|
||
|
|
exp[index] = value;
|
||
|
|
log[value] = index;
|
||
|
|
value <<= 1;
|
||
|
|
if (value & 0x100) {
|
||
|
|
value ^= 0x11d;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (let index = 255; index < 512; index += 1) {
|
||
|
|
exp[index] = exp[index - 255];
|
||
|
|
}
|
||
|
|
return { exp, log };
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrMultiply(left, right, tables) {
|
||
|
|
if (!left || !right) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
return tables.exp[tables.log[left] + tables.log[right]];
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrGeneratorPolynomial(degree, tables) {
|
||
|
|
let poly = [1];
|
||
|
|
for (let index = 0; index < degree; index += 1) {
|
||
|
|
const next = new Array(poly.length + 1).fill(0);
|
||
|
|
poly.forEach((coefficient, coefficientIndex) => {
|
||
|
|
next[coefficientIndex] ^= coefficient;
|
||
|
|
next[coefficientIndex + 1] ^= qrMultiply(coefficient, tables.exp[index], tables);
|
||
|
|
});
|
||
|
|
poly = next;
|
||
|
|
}
|
||
|
|
return poly;
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrReedSolomon(data, eccLength) {
|
||
|
|
const tables = qrGaloisTables();
|
||
|
|
const generator = qrGeneratorPolynomial(eccLength, tables);
|
||
|
|
const ecc = new Array(eccLength).fill(0);
|
||
|
|
|
||
|
|
data.forEach((byte) => {
|
||
|
|
const factor = byte ^ ecc.shift();
|
||
|
|
ecc.push(0);
|
||
|
|
for (let index = 0; index < eccLength; index += 1) {
|
||
|
|
ecc[index] ^= qrMultiply(generator[index + 1], factor, tables);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
return ecc;
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrAppendBits(bits, value, length) {
|
||
|
|
for (let bit = length - 1; bit >= 0; bit -= 1) {
|
||
|
|
bits.push((value >>> bit) & 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrEncodeBytes(text) {
|
||
|
|
const bytes = Array.from(new TextEncoder().encode(text));
|
||
|
|
if (bytes.length > 134) {
|
||
|
|
throw new Error("Payment URI is too long for the QR code. Shorten the label or amount.");
|
||
|
|
}
|
||
|
|
|
||
|
|
const bits = [];
|
||
|
|
qrAppendBits(bits, 0b0100, 4);
|
||
|
|
qrAppendBits(bits, bytes.length, 8);
|
||
|
|
bytes.forEach((byte) => qrAppendBits(bits, byte, 8));
|
||
|
|
const dataBitCapacity = 136 * 8;
|
||
|
|
const terminator = Math.min(4, dataBitCapacity - bits.length);
|
||
|
|
qrAppendBits(bits, 0, terminator);
|
||
|
|
while (bits.length % 8) {
|
||
|
|
bits.push(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = [];
|
||
|
|
for (let index = 0; index < bits.length; index += 8) {
|
||
|
|
data.push(bits.slice(index, index + 8).reduce((value, bit) => (value << 1) | bit, 0));
|
||
|
|
}
|
||
|
|
for (let pad = 0xec; data.length < 136; pad = pad === 0xec ? 0x11 : 0xec) {
|
||
|
|
data.push(pad);
|
||
|
|
}
|
||
|
|
|
||
|
|
const block1 = data.slice(0, 68);
|
||
|
|
const block2 = data.slice(68, 136);
|
||
|
|
const ecc1 = qrReedSolomon(block1, 18);
|
||
|
|
const ecc2 = qrReedSolomon(block2, 18);
|
||
|
|
const codewords = [];
|
||
|
|
for (let index = 0; index < 68; index += 1) {
|
||
|
|
codewords.push(block1[index], block2[index]);
|
||
|
|
}
|
||
|
|
for (let index = 0; index < 18; index += 1) {
|
||
|
|
codewords.push(ecc1[index], ecc2[index]);
|
||
|
|
}
|
||
|
|
return codewords.flatMap((byte) => {
|
||
|
|
const out = [];
|
||
|
|
qrAppendBits(out, byte, 8);
|
||
|
|
return out;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrFormatBits() {
|
||
|
|
let data = 0b01000; // ECC level L, mask 0.
|
||
|
|
let value = data << 10;
|
||
|
|
const generator = 0x537;
|
||
|
|
for (let bit = 14; bit >= 10; bit -= 1) {
|
||
|
|
if ((value >>> bit) & 1) {
|
||
|
|
value ^= generator << (bit - 10);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return ((data << 10) | value) ^ 0x5412;
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrSet(matrix, reserved, row, col, value, isReserved = true) {
|
||
|
|
if (row < 0 || col < 0 || row >= matrix.length || col >= matrix.length) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
matrix[row][col] = Boolean(value);
|
||
|
|
if (isReserved) {
|
||
|
|
reserved[row][col] = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrFinder(matrix, reserved, row, col) {
|
||
|
|
for (let y = -1; y <= 7; y += 1) {
|
||
|
|
for (let x = -1; x <= 7; x += 1) {
|
||
|
|
const r = row + y;
|
||
|
|
const c = col + x;
|
||
|
|
const dark = x >= 0 && x <= 6 && y >= 0 && y <= 6
|
||
|
|
&& (x === 0 || x === 6 || y === 0 || y === 6 || (x >= 2 && x <= 4 && y >= 2 && y <= 4));
|
||
|
|
qrSet(matrix, reserved, r, c, dark);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrAlignment(matrix, reserved, row, col) {
|
||
|
|
for (let y = -2; y <= 2; y += 1) {
|
||
|
|
for (let x = -2; x <= 2; x += 1) {
|
||
|
|
qrSet(matrix, reserved, row + y, col + x, Math.max(Math.abs(x), Math.abs(y)) !== 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function qrBuildMatrix(text) {
|
||
|
|
const size = 41;
|
||
|
|
const matrix = Array.from({ length: size }, () => new Array(size).fill(false));
|
||
|
|
const reserved = Array.from({ length: size }, () => new Array(size).fill(false));
|
||
|
|
|
||
|
|
qrFinder(matrix, reserved, 0, 0);
|
||
|
|
qrFinder(matrix, reserved, 0, size - 7);
|
||
|
|
qrFinder(matrix, reserved, size - 7, 0);
|
||
|
|
qrAlignment(matrix, reserved, 34, 34);
|
||
|
|
|
||
|
|
for (let index = 8; index < size - 8; index += 1) {
|
||
|
|
qrSet(matrix, reserved, 6, index, index % 2 === 0);
|
||
|
|
qrSet(matrix, reserved, index, 6, index % 2 === 0);
|
||
|
|
}
|
||
|
|
qrSet(matrix, reserved, 33, 8, true);
|
||
|
|
|
||
|
|
for (let index = 0; index < 9; index += 1) {
|
||
|
|
if (index !== 6) {
|
||
|
|
reserved[8][index] = true;
|
||
|
|
reserved[index][8] = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (let index = 0; index < 8; index += 1) {
|
||
|
|
reserved[8][size - 1 - index] = true;
|
||
|
|
reserved[size - 1 - index][8] = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const bits = qrEncodeBytes(text);
|
||
|
|
let bitIndex = 0;
|
||
|
|
let upward = true;
|
||
|
|
for (let col = size - 1; col > 0; col -= 2) {
|
||
|
|
if (col === 6) {
|
||
|
|
col -= 1;
|
||
|
|
}
|
||
|
|
for (let step = 0; step < size; step += 1) {
|
||
|
|
const row = upward ? size - 1 - step : step;
|
||
|
|
for (let offset = 0; offset < 2; offset += 1) {
|
||
|
|
const c = col - offset;
|
||
|
|
if (reserved[row][c]) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const dark = bitIndex < bits.length ? bits[bitIndex] === 1 : false;
|
||
|
|
matrix[row][c] = ((row + c) % 2 === 0) ? !dark : dark;
|
||
|
|
bitIndex += 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
upward = !upward;
|
||
|
|
}
|
||
|
|
|
||
|
|
const format = qrFormatBits();
|
||
|
|
for (let index = 0; index < 15; index += 1) {
|
||
|
|
const bit = ((format >>> index) & 1) === 1;
|
||
|
|
const first = [
|
||
|
|
[8, 0], [8, 1], [8, 2], [8, 3], [8, 4], [8, 5], [8, 7], [8, 8],
|
||
|
|
[7, 8], [5, 8], [4, 8], [3, 8], [2, 8], [1, 8], [0, 8]
|
||
|
|
][index];
|
||
|
|
const second = index < 8 ? [size - 1 - index, 8] : [8, size - 15 + index];
|
||
|
|
qrSet(matrix, reserved, first[0], first[1], bit);
|
||
|
|
qrSet(matrix, reserved, second[0], second[1], bit);
|
||
|
|
}
|
||
|
|
|
||
|
|
return matrix;
|
||
|
|
}
|
||
|
|
|
||
|
|
function drawPaymentQr(canvas, text, label) {
|
||
|
|
if (!canvas) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const size = canvas.width || 256;
|
||
|
|
const context = canvas.getContext("2d");
|
||
|
|
if (!context) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
context.fillStyle = "#ffffff";
|
||
|
|
context.fillRect(0, 0, size, size);
|
||
|
|
context.fillStyle = "#101820";
|
||
|
|
|
||
|
|
try {
|
||
|
|
const matrix = qrBuildMatrix(text);
|
||
|
|
const modules = matrix.length;
|
||
|
|
const quiet = 4;
|
||
|
|
const cell = Math.floor(size / (modules + quiet * 2));
|
||
|
|
const offset = Math.floor((size - cell * modules) / 2);
|
||
|
|
matrix.forEach((row, y) => {
|
||
|
|
row.forEach((dark, x) => {
|
||
|
|
if (dark) {
|
||
|
|
context.fillRect(offset + x * cell, offset + y * cell, cell, cell);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
setPaymentMessage("");
|
||
|
|
} catch (error) {
|
||
|
|
context.fillStyle = "#101820";
|
||
|
|
context.font = "16px sans-serif";
|
||
|
|
context.textAlign = "center";
|
||
|
|
context.fillText("QR unavailable", size / 2, size / 2 - 8);
|
||
|
|
context.font = "12px sans-serif";
|
||
|
|
context.fillText("Shorten request text", size / 2, size / 2 + 14);
|
||
|
|
setPaymentMessage(String(error), "error");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (label) {
|
||
|
|
const footerHeight = 28;
|
||
|
|
context.fillStyle = "rgba(255,255,255,0.92)";
|
||
|
|
context.fillRect(0, size - footerHeight, size, footerHeight);
|
||
|
|
context.fillStyle = "#101820";
|
||
|
|
context.font = "13px sans-serif";
|
||
|
|
context.textAlign = "center";
|
||
|
|
const trimmed = label.length > 30 ? `${label.slice(0, 27)}...` : label;
|
||
|
|
context.fillText(trimmed, size / 2, size - 10);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function drawEmptyPaymentQr(canvas) {
|
||
|
|
if (!canvas) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const size = canvas.width || 256;
|
||
|
|
const context = canvas.getContext("2d");
|
||
|
|
if (!context) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
context.fillStyle = "#ffffff";
|
||
|
|
context.fillRect(0, 0, size, size);
|
||
|
|
context.fillStyle = "#101820";
|
||
|
|
context.font = "16px sans-serif";
|
||
|
|
context.textAlign = "center";
|
||
|
|
context.fillText("Load wallet", size / 2, size / 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPaymentQr(uri, label) {
|
||
|
|
if (!uri) {
|
||
|
|
drawEmptyPaymentQr(paymentQrCanvas);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
drawPaymentQr(paymentQrCanvas, uri, label);
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateReceiveDetails() {
|
||
|
|
if (receiveRegistration) {
|
||
|
|
receiveRegistration.textContent = registrationRegistered ? "Registered" : "Unregistered";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function updatePaymentRequest() {
|
||
|
|
const uri = paymentRequestUri();
|
||
|
|
if (paymentUriInput) {
|
||
|
|
paymentUriInput.value = uri;
|
||
|
|
}
|
||
|
|
if (paymentQrLabel) {
|
||
|
|
paymentQrLabel.textContent = paymentLabelInput?.value?.trim() || "Payment Request";
|
||
|
|
}
|
||
|
|
renderPaymentQr(uri, paymentQrLabel?.textContent || "");
|
||
|
|
updateReceiveDetails();
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refreshReceiveTokenList() {
|
||
|
|
const invoke = window.__TAURI__?.core?.invoke;
|
||
|
|
const broadcastNode = activeBroadcastNode();
|
||
|
|
if (!invoke || !walletLoaded || !broadcastNode || !paymentAssetSelect) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const requestId = ++receiveTokenListRequestId;
|
||
|
|
const selected = paymentAssetSelect.value || activeBaseCoin();
|
||
|
|
paymentAssetSelect.innerHTML = "";
|
||
|
|
const baseOption = document.createElement("option");
|
||
|
|
baseOption.value = activeBaseCoin();
|
||
|
|
baseOption.textContent = activeBaseCoin();
|
||
|
|
paymentAssetSelect.appendChild(baseOption);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const tokens = await invokeWalletRpcWithBackoff(
|
||
|
|
"Token list lookup",
|
||
|
|
() => invoke("token_list", { broadcastNode }),
|
||
|
|
() => requestId === receiveTokenListRequestId && walletLoaded
|
||
|
|
);
|
||
|
|
if (requestId !== receiveTokenListRequestId || !walletLoaded) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
(Array.isArray(tokens) ? tokens : []).forEach((token) => {
|
||
|
|
const ticker = String(token?.ticker || "").trim();
|
||
|
|
if (!ticker || ticker.toLowerCase() === activeBaseCoin().toLowerCase()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const option = document.createElement("option");
|
||
|
|
option.value = ticker;
|
||
|
|
option.textContent = ticker;
|
||
|
|
paymentAssetSelect.appendChild(option);
|
||
|
|
});
|
||
|
|
paymentAssetSelect.value = [...paymentAssetSelect.options].some((option) => option.value === selected)
|
||
|
|
? selected
|
||
|
|
: activeBaseCoin();
|
||
|
|
} catch (error) {
|
||
|
|
setPaymentMessage(String(error), "error");
|
||
|
|
} finally {
|
||
|
|
updatePaymentRequest();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function copyPaymentUri() {
|
||
|
|
const uri = paymentRequestUri();
|
||
|
|
if (!uri || !(await writeClipboardText(uri))) {
|
||
|
|
setPaymentMessage("Payment URI is not available to copy.", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setPaymentMessage("Payment URI copied.", "success");
|
||
|
|
}
|
||
|
|
|
||
|
|
async function copyPaymentQr() {
|
||
|
|
if (!paymentQrCanvas || !navigator.clipboard || typeof ClipboardItem === "undefined") {
|
||
|
|
await copyPaymentUri();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
paymentQrCanvas.toBlob(async (blob) => {
|
||
|
|
if (!blob) {
|
||
|
|
setPaymentMessage("Payment QR is not available to copy.", "error");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.write([
|
||
|
|
new ClipboardItem({
|
||
|
|
[blob.type]: blob
|
||
|
|
})
|
||
|
|
]);
|
||
|
|
setPaymentMessage("Payment request QR copied.", "success");
|
||
|
|
} catch (_) {
|
||
|
|
await copyPaymentUri();
|
||
|
|
}
|
||
|
|
}, "image/png");
|
||
|
|
}
|
||
|
|
|
||
|
|
function refreshReceivePage() {
|
||
|
|
updateReceiveDetails();
|
||
|
|
updatePaymentRequest();
|
||
|
|
refreshReceiveTokenList();
|
||
|
|
}
|
||
|
|
|
||
|
|
[paymentAssetSelect, paymentAmountInput, paymentLabelInput].forEach((input) => {
|
||
|
|
input?.addEventListener("input", updatePaymentRequest);
|
||
|
|
input?.addEventListener("change", updatePaymentRequest);
|
||
|
|
});
|
||
|
|
|
||
|
|
copyPaymentUriButton?.addEventListener("click", copyPaymentUri);
|
||
|
|
copyPaymentQrButton?.addEventListener("click", copyPaymentQr);
|