Files
pezkuwi-wallet-utils/docs/root-signing/index.html
T
SatoshiQaziMuhammed 306c65532e Name each wallet step on the root signing page (#82)
The wallet answers DApp requests one at a time and waits without limit for each, so a
tap that reaches a stalled wallet showed nothing. The page now names the step it is
waiting on, says after 45 s that the wallet is not answering and how to reset it, and
disables the button while a request is pending so repeated taps do not queue more.
2026-11-05 03:21:41 +04:00

282 lines
14 KiB
HTML

<!doctype html>
<html lang="ku">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guhertina mifteya kokê</title>
<!--
Root signing page for the wallet's signed config. Open it in the Pezkuwi Wallet DApp browser.
It shows the drafted root (proposals/root.N.json), has the holder's wallet sign <Bytes>payload</Bytes>,
checks each signature the way the wallet will, and assembles signed/root.N.json once a threshold of the
previous root's keys and of the new root's keys have signed. Wallet 1.3.0 accepts sr25519 under the
"substrate" signing context only, so that is what counts toward the thresholds.
-->
<script src="vendor/bundle-pezkuwi-util.js"></script>
<script src="vendor/bundle-pezkuwi-util-crypto.js"></script>
<script src="vendor/bundle-polkadot-util.js"></script>
<script src="vendor/bundle-polkadot-util-crypto.js"></script>
<style>
:root { --bg: #08090E; --panel: rgba(153,158,199,0.10); --line: rgba(153,158,199,0.16); --text: rgba(255,255,255,0.88);
--muted: rgba(255,255,255,0.60); --accent: #009639; --ok: #2FC864; --warn: #EBC50A; --bad: #E53450; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, sans-serif; }
main { max-width: 560px; margin: 0 auto; padding: 20px 16px 40px; display: flex; flex-direction: column; gap: 14px; }
h1 { margin: 0; font-size: 22px; }
p { margin: 0; color: var(--muted); }
.panel { background: var(--panel); border-radius: 12px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 12px; border-bottom: 1px solid var(--line); }
.row:last-child { border-bottom: 0; }
.row span:first-child { color: var(--muted); }
.mono { font-family: ui-monospace, monospace; word-break: break-all; }
.fp { font-size: 17px; font-weight: 700; letter-spacing: 1px; }
button { min-height: 48px; border-radius: 12px; border: 0; background: var(--accent); color: #fff; font: inherit; font-weight: 600; cursor: pointer; }
button.secondary { background: rgba(153,158,199,0.16); color: var(--text); }
button:disabled { opacity: 0.45; cursor: default; }
textarea { width: 100%; min-height: 80px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--text); padding: 10px; font: 13px ui-monospace, monospace; }
.ok { color: var(--ok); } .warn { color: var(--warn); } .bad { color: var(--bad); }
.note { padding: 12px; border-radius: 12px; font-size: 14px; }
.note.warn { background: rgba(237,206,54,0.14); } .note.bad { background: rgba(229,52,80,0.14); } .note.ok { background: rgba(47,200,100,0.12); }
label { display: flex; flex-direction: column; gap: 6px; color: var(--muted); font-size: 13px; }
[hidden] { display: none !important; }
</style>
</head>
<body>
<main>
<h1>Guhertina mifteya kokê</h1>
<p id="summary">Tê barkirin…</p>
<div id="error" class="note bad" hidden></div>
<div class="panel" id="facts" hidden>
<div class="row"><span>Guherto</span><span id="version"></span></div>
<div class="row"><span>Sînor</span><span id="threshold"></span></div>
<div class="row"><span>Dema derbasbûnê</span><span id="expires"></span></div>
</div>
<div id="keysBlock" hidden>
<p>Mifteyên kokê yên nû (sr25519, hesabên cîzdanê)</p>
<div class="panel" id="newKeys"></div>
</div>
<div class="panel row" id="fpBlock" hidden><span>Şopa pêşniyarê</span><span class="mono fp" id="fingerprint"></span></div>
<p id="fpHint" hidden>Berî îmzekirinê vê şopê bi telefonê bi xwediyên din re bide ber hev.</p>
<button id="signButton" hidden>Bi cîzdanê îmze bike</button>
<div id="signResult" class="note" hidden></div>
<button id="shareButton" class="secondary" hidden>Koda îmzeya min kopî bike</button>
<div id="collect" hidden>
<p id="tally"></p>
<div class="panel" id="holders"></div>
</div>
<label id="pasteBlock" hidden>Koda îmzeyê ya xwediyekî din zêde bike
<textarea id="paste" placeholder="Kodê li vir bixe"></textarea>
</label>
<button id="addButton" class="secondary" hidden>Kodê zêde bike</button>
<button id="downloadButton" hidden disabled>root.json daxîne</button>
</main>
<script>
(async () => {
const pz = window.pezkuwiUtilCrypto, pzu = window.pezkuwiUtil, pk = window.polkadotUtilCrypto;
const RAW = 'https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/signed/';
const $ = (id) => document.getElementById(id);
const enc = new TextEncoder();
const show = (id, on = true) => { $(id).hidden = !on; };
const fail = (message) => { $('error').textContent = message; show('error'); $('summary').textContent = ''; };
const b64 = (u8) => btoa(String.fromCharCode(...u8));
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
const hex = (u8) => Array.from(u8, (b) => b.toString(16).padStart(2, '0')).join('');
const sha256 = async (u8) => new Uint8Array(await crypto.subtle.digest('SHA-256', u8));
const wrap = (payload) => pzu.u8aConcat(enc.encode('<Bytes>'), payload, enc.encode('</Bytes>'));
async function readEnvelope(text) {
const envelope = JSON.parse(text);
const payload = unb64(envelope.signed);
return { payload, root: JSON.parse(new TextDecoder().decode(payload)), signatures: envelope.signatures || [] };
}
try {
await pz.cryptoWaitReady();
await pk.cryptoWaitReady();
// The newest published root: root.json, then root.N.json while they exist.
let previous = await readEnvelope(await (await fetch(RAW + 'root.json', { cache: 'no-store' })).text());
for (;;) {
const next = await fetch(RAW + `root.${previous.root.version + 1}.json`, { cache: 'no-store' });
if (!next.ok) break;
previous = await readEnvelope(await next.text());
}
const version = previous.root.version + 1;
const response = await fetch(`proposals/root.${version}.json`, { cache: 'no-store' });
if (!response.ok) { $('summary').textContent = `Ji bo guhertoya ${version} pêşniyar tune.`; return; }
const proposalText = await response.text();
const proposal = await readEnvelope(proposalText);
if (proposal.root.type !== 'root' || proposal.root.version !== version) throw new Error('Pêşniyar ne koka paşîn e.');
const message = wrap(proposal.payload);
const fingerprint = hex(await sha256(proposal.payload)).slice(0, 12).toUpperCase().match(/.{4}/g).join(' · ');
// keyId -> {address, roles}
const holders = new Map();
const addHolder = (root, role) => {
for (const keyId of root.root.keyids) {
const key = root.keys[keyId];
if (key.scheme !== 'sr25519') continue;
const entry = holders.get(keyId) || { public: unb64(key.public), roles: new Set() };
entry.roles.add(role);
holders.set(keyId, entry);
}
};
addHolder(previous.root, 'previous');
addHolder(proposal.root, 'next');
for (const entry of holders.values()) entry.address = pz.encodeAddress(entry.public, 42);
const signatures = new Map(); // keyId -> {sig, context}
function classify(keyId, sig) {
const entry = holders.get(keyId);
if (!entry) return null;
if (pk.sr25519Verify(message, sig, entry.public)) return 'substrate';
if (pz.sr25519Verify(message, sig, entry.public)) return 'bizinikiwi';
return null;
}
for (const { keyid, sig } of proposal.signatures) {
const bytes = unb64(sig);
const context = classify(keyid, bytes);
if (context) signatures.set(keyid, { sig: bytes, context });
}
$('summary').textContent = `Pêşniyar: koka config-a îmzekirî ya cîzdanê. Ji ${proposal.root.root.keyids.length} xwediyên nû ${proposal.root.root.threshold} îmze, ji yên heyî ${previous.root.root.threshold} îmze pêwîst in.`;
$('version').textContent = `${previous.root.version} → ${version}`;
$('threshold').textContent = `${proposal.root.root.threshold} ji ${proposal.root.root.keyids.length}`;
$('expires').textContent = new Date(proposal.root.expires * 1000).toLocaleDateString('de-DE');
for (const keyId of proposal.root.root.keyids) {
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = '<span></span><span class="mono"></span>';
row.children[0].textContent = holders.get(keyId).address.slice(0, 6) + '…';
row.children[1].textContent = holders.get(keyId).address;
$('newKeys').appendChild(row);
}
$('fingerprint').textContent = fingerprint;
['facts', 'keysBlock', 'fpBlock', 'fpHint', 'collect', 'pasteBlock', 'addButton', 'downloadButton'].forEach((id) => show(id));
function render() {
$('holders').replaceChildren();
const counts = { previous: 0, next: 0 };
for (const [keyId, entry] of holders) {
const signed = signatures.get(keyId);
if (signed && signed.context === 'substrate') entry.roles.forEach((role) => counts[role]++);
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = '<span class="mono"></span><span></span>';
const roles = [...entry.roles].map((r) => (r === 'previous' ? 'heyî' : 'nû')).join(' + ');
row.children[0].textContent = `${entry.address.slice(0, 6)}…${entry.address.slice(-4)} (${roles})`;
row.children[1].textContent = !signed ? 'Li bendê' : signed.context === 'substrate' ? 'Îmzekirî' : 'Çewt: bizinikiwi';
row.children[1].className = !signed ? '' : signed.context === 'substrate' ? 'ok' : 'bad';
$('holders').appendChild(row);
}
const need = { previous: previous.root.root.threshold, next: proposal.root.root.threshold };
$('tally').textContent = `Îmze: heyî ${counts.previous} ji ${need.previous} · nû ${counts.next} ji ${need.next}`;
$('downloadButton').disabled = !(counts.previous >= need.previous && counts.next >= need.next);
}
render();
$('downloadButton').onclick = () => {
const valid = [...signatures].filter(([, s]) => s.context === 'substrate').map(([keyid, s]) => ({ keyid, sig: b64(s.sig) }));
const envelope = JSON.parse(proposalText);
envelope.signatures = valid;
const blob = new Blob([JSON.stringify(envelope, null, 1) + '\n'], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `root.${version}.json`;
a.click();
};
$('addButton').onclick = () => {
const text = $('paste').value.trim();
try {
const { keyid, sig } = JSON.parse(atob(text));
const bytes = unb64(sig);
const context = classify(keyid, bytes);
if (!context) throw new Error('bad');
signatures.set(keyid, { sig: bytes, context });
$('paste').value = '';
render();
} catch (e) {
alert('Ev kod ji bo vê pêşniyarê ne derbasdar e.');
}
};
// The wallet's injected provider; the signing page asks for accounts only to find the holder's key.
const injected = window.injectedWeb3 && (injectedWeb3['pezkuwi-wallet'] || injectedWeb3['pezkuwi-js'] || injectedWeb3['polkadot-js']);
if (!injected) {
$('signResult').textContent = 'Ji bo îmzekirinê vê rûpelê di geroka DApp ya Pezkuwi Cîzdan de veke.';
$('signResult').className = 'note warn';
show('signResult');
return;
}
show('signButton');
// The wallet answers DApp requests one at a time and waits without limit for each, so a request it never
// shows blocks every later one. Each step is named on the page, so a stalled wallet says where it stopped.
const step = (text) => {
$('signResult').textContent = text;
$('signResult').className = 'note';
show('signResult');
};
const waitFor = (promise, text) => {
step(text);
const stalled = setTimeout(() => {
$('signResult').textContent = text + ' Cîzdan bersiv nade: geroka DApp bi tevahî bigire, rûpelê dîsa veke û carekê bitikîne.';
$('signResult').className = 'note warn';
}, 45000);
return promise.finally(() => clearTimeout(stalled));
};
$('signButton').onclick = async () => {
$('signButton').disabled = true;
try {
const extension = await waitFor(injected.enable('Pezkuwi root signing'), 'Bi cîzdanê ve tê girêdan…');
const accounts = await waitFor(extension.accounts.get(), 'Hesab têne xwendin…');
const mine = accounts.map((a) => ({ address: a.address, keyId: null, public: pz.decodeAddress(a.address) }));
for (const account of mine) account.keyId = hex(await sha256(account.public));
const holder = mine.find((a) => holders.has(a.keyId));
if (!holder) throw new Error('Di vê cîzdanê de hesabekî xwediyê kokê tune.');
const { signature } = await waitFor(
extension.signer.signRaw({ address: holder.address, data: pzu.u8aToHex(message), type: 'bytes' }),
'Li benda îmzeyê ye: di cîzdanê de peyamê piştrast bike…');
let sig = pzu.hexToU8a(signature);
if (sig.length === 65 && sig[0] === 1) sig = sig.slice(1); // MultiSignature: 0x01 = sr25519
const context = classify(holder.keyId, sig);
signatures.set(holder.keyId, { sig, context });
render();
const box = $('signResult');
if (context === 'substrate') {
box.textContent = 'Îmze derbasdar e. Kodê kopî bike û ji xwediyê ku pelê berhev dike re bişîne.';
box.className = 'note ok';
const code = btoa(JSON.stringify({ keyid: holder.keyId, sig: b64(sig) }));
$('shareButton').onclick = () => navigator.clipboard.writeText(code).then(() => alert('Hate kopîkirin'), () => prompt('Kod', code));
show('shareButton');
} else {
box.textContent = context === 'bizinikiwi'
? 'Cîzdan bi çarçoveya "bizinikiwi" îmze kir; cîzdanên 1.3.0 vê qebûl nakin. Ji pêşdebiran re bêje.'
: 'Îmze nehat piştrastkirin.';
box.className = 'note bad';
}
show('signResult');
} catch (e) {
$('signResult').textContent = e.message || String(e);
$('signResult').className = 'note bad';
show('signResult');
} finally {
$('signButton').disabled = false;
}
};
} catch (e) {
fail(e.message || String(e));
}
})();
</script>
</body>
</html>