Files
pezkuwi-wallet-utils/docs/root-signing/index.html
T
SatoshiQaziMuhammed c3bc55d37c Sign the root over WalletConnect (#83)
* Sign the root over WalletConnect

Wallet 1.3.0's DApp browser never delivers injected-provider requests, so the page could
not get a signature from any installed wallet. WalletConnect is the route installed
wallets answer: on the phone the page opens the wallet by its pezkuwiwallet://wc link,
elsewhere it shows a code for the wallet to scan, then asks for polkadot_signMessage on
<Bytes>payload</Bytes>. Measured over a real session on the 1.3.0 signing code: the
signature verifies under the "substrate" context, which is what the thresholds count.

The injected provider stays as a second button for wallets where it works. The
WalletConnect bundle is built from scripts/root/wc (pinned versions, node build.mjs).

* Open the wallet by a fixed link, not the peer's redirect

The redirect in a WalletConnect session's peer metadata is supplied by whichever wallet
connects, so following it let a connecting peer navigate the signing page anywhere,
javascript: included.
2026-11-05 04:25:23 +04:00

324 lines
17 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. The holder's wallet signs over WalletConnect (or the
DApp browser's injected provider where it works).
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>
<script src="vendor/walletconnect.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; }
a.note { display: block; color: var(--ok); }
[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="wcButton" hidden>Bi Pezkuwi Cîzdan îmze bike (WalletConnect)</button>
<a id="wcOpen" class="note" hidden>Cîzdan venebû? Li vir bitikîne.</a>
<div id="wcQrBlock" class="panel" hidden><img id="wcQr" alt="Koda WalletConnect" style="display:block;margin:12px auto;max-width:100%;border-radius:8px"></div>
<button id="signButton" class="secondary" hidden>Bi geroka DApp î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 answers each request only once its own screen is confirmed, and an unanswered one blocks the
// next, so each step is named on the page and a stall says where it stopped.
const step = (text, kind = '') => {
$('signResult').textContent = text;
$('signResult').className = 'note ' + kind;
show('signResult');
};
const waitFor = (promise, text, stallHint) => {
step(text);
const stalled = setTimeout(() => step(text + ' ' + stallHint, 'warn'), 45000);
return promise.finally(() => clearTimeout(stalled));
};
async function findHolder(addresses) {
for (const address of addresses) {
const keyId = hex(await sha256(pz.decodeAddress(address)));
if (holders.has(keyId)) return { address, keyId };
}
throw new Error('Cîzdana hilbijartî ne xwediyê kokê ye. Di Pezkuwi Cîzdan de cîzdana xwediyê kokê hilbijêre û dîsa biceribîne.');
}
function accept(holder, signature) {
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();
if (context === 'substrate') {
step('Îmze derbasdar e. Kodê kopî bike û ji xwediyê ku pelê berhev dike re bişîne.', '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 {
step(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.', 'bad');
}
}
const guarded = (button, run) => async () => {
$(button).disabled = true;
try { await run(); } catch (e) { step(e.message || String(e), 'bad'); } finally { $(button).disabled = false; }
};
// WalletConnect: the route every installed wallet answers. On the phone the wallet is opened by its own link;
// elsewhere the wallet scans a code. Wallet 1.3.0 signs polkadot_signMessage under the "substrate" context
// (measured 11-05 over a real session), and a message already wrapped in <Bytes> is signed as given.
const WC_PROJECT_ID = '8292a793b7640e8364c378e331e76d04';
const RELAY_CHAIN = 'polkadot:1aa94987791a5544e9667ec249d2cef1';
const onPhone = /Android/i.test(navigator.userAgent);
let wc = null;
show('wcButton');
$('wcButton').onclick = guarded('wcButton', async () => {
step('WalletConnect tê amadekirin…');
wc = wc || await RootSigningWC.SignClient.init({
projectId: WC_PROJECT_ID,
metadata: { name: 'Pezkuwi root signing', description: 'Guhertina mifteya kokê', url: location.origin, icons: [] },
});
const { uri, approval } = await wc.connect({
optionalNamespaces: { polkadot: { chains: [RELAY_CHAIN], methods: ['polkadot_signMessage'], events: [] } },
});
const walletLink = 'pezkuwiwallet://wc?uri=' + encodeURIComponent(uri);
if (onPhone) {
$('wcOpen').href = walletLink;
show('wcOpen');
location.href = walletLink;
} else {
$('wcQr').src = await RootSigningWC.qrDataUrl(uri, { width: 280, margin: 2 });
show('wcQrBlock');
}
const session = await waitFor(approval(),
onPhone ? 'Di Pezkuwi Cîzdan de girêdanê bipejirîne…' : 'Kodê bi Pezkuwi Cîzdan bixwîne (Mîheng → WalletConnect) û girêdanê bipejirîne…',
'Cîzdan bersiv nade: rûpelê nû bike û dîsa biceribîne.');
show('wcOpen', false);
show('wcQrBlock', false);
const holder = await findHolder(session.namespaces.polkadot.accounts.map((a) => a.split(':').pop()));
const request = wc.request({
topic: session.topic, chainId: RELAY_CHAIN,
request: { method: 'polkadot_signMessage', params: { address: holder.address, message: pzu.u8aToHex(message) } },
});
// A fixed link: the redirect in the peer's metadata is whatever the connecting wallet claims.
if (onPhone) location.href = 'pezkuwiwallet://';
const { signature } = await waitFor(request, 'Li benda îmzeyê ye: di cîzdanê de peyamê piştrast bike…',
'Cîzdan bersiv nade: Pezkuwi Cîzdan veke, daxwazê bibîne an rûpelê nû bike û dîsa biceribîne.');
accept(holder, signature);
wc.disconnect({ topic: session.topic, reason: { code: 6000, message: 'Signed' } }).catch(() => {});
});
// The provider the wallet's DApp browser injects. Wallet 1.3.0 never delivers its requests (the bridge calls
// an interface name the app does not register), so it is offered only beside WalletConnect.
const injected = window.injectedWeb3 && (injectedWeb3['pezkuwi-wallet'] || injectedWeb3['pezkuwi-js'] || injectedWeb3['polkadot-js']);
if (injected) {
show('signButton');
const stall = 'Cîzdan bersiv nade: bişkoka WalletConnect bi kar bîne.';
$('signButton').onclick = guarded('signButton', async () => {
const extension = await waitFor(injected.enable('Pezkuwi root signing'), 'Bi cîzdanê ve tê girêdan…', stall);
const accounts = await waitFor(extension.accounts.get(), 'Hesab têne xwendin…', stall);
const holder = await findHolder(accounts.map((a) => a.address));
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…', stall);
accept(holder, signature);
});
}
} catch (e) {
fail(e.message || String(e));
}
})();
</script>
</body>
</html>