mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-utils.git
synced 2026-11-05 16:51:07 +00:00
* 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.
324 lines
17 KiB
HTML
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>
|