diff --git a/app/src/lib/people.ts b/app/src/lib/people.ts index b1478a8..8bd76a5 100644 --- a/app/src/lib/people.ts +++ b/app/src/lib/people.ts @@ -60,6 +60,28 @@ async function ownerVia(client: PolkadotClient, username: string): Promise (u == null ? null : typeof u === 'string' + ? (/^0x([0-9a-f]{2})*$/i.test(u) ? new TextDecoder().decode(Uint8Array.from(u.slice(2).match(/../g) ?? [], b => parseInt(b, 16))) : u) + : new TextDecoder().decode(u)); + +async function usernameVia(client: PolkadotClient, ss58: string): Promise { + const q = client.getUnsafeApi().query.Resources.Consumers.getValue(ss58) as Promise<{ full_username?: Bytes; lite_username?: Bytes } | undefined>; + const r = await withTimeout(q, PEOPLE_QUERY_MS); + if (r === TIMED_OUT) throw new Error('People chain no respondió'); + return bytesToText(r?.full_username ?? r?.lite_username); +} + +/** Username de una cuenta (`Resources.Consumers`): el completo si es persona verificada, si no el lite. */ +export async function usernameOf(ss58: string): Promise { + if (!isInsideContainerSync()) return usernameVia(viaPublic(), ss58); + try { + return await usernameVia(await viaHost(), ss58); + } catch { + return usernameVia(viaPublic(), ss58); + } +} + /** * Llave pública (hex) dueña de `username`, o `null` si el username no existe. * Lanza si no se pudo consultar: eso es "sin comprobar", no "falso". diff --git a/app/src/lib/signer.ts b/app/src/lib/signer.ts index 39f22ff..36da459 100644 --- a/app/src/lib/signer.ts +++ b/app/src/lib/signer.ts @@ -24,7 +24,7 @@ import { hexToU8a, u8aToHex } from '@polkadot/util'; import { encodeAddress } from '@polkadot/util-crypto'; import { waitForHost, withTimeout, describeError, TIMED_OUT, HOST_QUERY_MS, HOST_SUBMIT_MS } from './host'; import { APP_DOTNS } from './network'; -import { usernameOwner } from './people'; +import { usernameOf, usernameOwner } from './people'; export type SignerKind = 'identity' | 'app' | 'rehearsal'; @@ -63,8 +63,10 @@ export function hasIdentitySigner(): boolean { return identity !== null; } -/** Abre un diálogo: llamar solo desde un gesto del usuario. */ -export async function connectSpeaker(): Promise { +/** Abre un diálogo: llamar solo desde un gesto del usuario. + * `typedUsername`: el que escribe la persona cuando el host no lo entrega + * (Polkadot Desktop 0.1.4: «No primary username for this session»). */ +export async function connectSpeaker(typedUsername?: string): Promise { const inside = isInsideContainerSync(); if (inside && !(await waitForHost())) throw new Error(`No hay canal con Polkadot App. Abre ${APP_DOTNS} desde la app.`); @@ -97,6 +99,14 @@ export async function connectSpeaker(): Promise { identityNote = u === TIMED_OUT ? 'el host no entregó tu username a tiempo (¿aprobaste compartir tu identidad?)' : !u.ok ? `el host no entregó tu username: ${describeError(u.error)}` : 'tu cuenta no tiene username'; + // Sin username del host: el escrito o la cuenta del wallet que tenga username en People chain. + // Firmar con la identidad solo necesita su llave pública (getLegacyAccountSigner). + try { + const found = await identityWithoutHostUsername(typedUsername); + if (found) { username = found.username; identity = found.identity; identityNote = null; } + } catch (e) { + identityNote = `${identityNote}; ${(e as Error).message}`; + } } else { try { const owner = await usernameOwner(username); @@ -126,6 +136,26 @@ export async function connectSpeaker(): Promise { return speaker; } +async function identityWithoutHostUsername(typed?: string): Promise<{ username: string; identity: NonNullable } | null> { + const ap = await withTimeout(getAccountsProvider(), HOST_QUERY_MS); + if (ap === TIMED_OUT || !ap) throw new Error('el host no entregó el proveedor de cuentas'); + const name = typed?.trim().toLowerCase(); + if (name) { + const owner = await usernameOwner(name); + if (!owner) throw new Error(`${name} no aparece en People chain`); + const publicKey = hexToU8a(owner); + return { username: name, identity: { address: encodeAddress(publicKey, 42), pubkey: owner, signer: ap.getLegacyAccountSigner({ publicKey, name }) } }; + } + const accounts = await withTimeout(ap.getLegacyAccounts().match(v => v, () => []), HOST_QUERY_MS); + if (accounts === TIMED_OUT) return null; + for (const a of accounts) { + const address = encodeAddress(a.publicKey, 42); + const n = await usernameOf(address).catch(() => null); + if (n) return { username: n, identity: { address, pubkey: u8aToHex(a.publicKey), signer: ap.getLegacyAccountSigner({ publicKey: a.publicKey, name: n }) } }; + } + return null; +} + /** Respaldo: firmar con la cuenta de la app si el host no firma con identidad. */ export function useAppAccount(): Speaker { if (!appKey) throw new Error('Conecta tu wallet primero.'); diff --git a/app/src/views/presenter.ts b/app/src/views/presenter.ts index 391ba43..91ae63a 100644 --- a/app/src/views/presenter.ts +++ b/app/src/views/presenter.ts @@ -204,10 +204,24 @@ export function renderPresenter(root: HTMLElement): Cleanup { walletRow.className = `check-row ${s ? 'ok' : ''}`; walletRow.innerHTML = s ? `${tag(s.kind === 'app' ? 'warn' : 'ok')}
${esc(s.username ?? shortAddr(s.address))} -
${signerNote(s)}
` +
${signerNote(s)}
+ ${s.kind === 'app' && !s.rehearsal ? `
+
` : ''}` : `${tag('idle')}
Wallet del speaker
Firma el recibo al final
`; start.disabled = !s; + walletRow.querySelector('#use-typed')?.addEventListener('click', async ev => { + const b = ev.currentTarget as HTMLButtonElement; + const typed = (walletRow.querySelector('#typed-user') as HTMLInputElement).value; + b.disabled = true; + err.innerHTML = ''; + try { + drawWallet(await connectSpeaker(typed)); + } catch (e) { + err.innerHTML = `
${icon('warningCircle')}${esc((e as Error).message)}
`; + b.disabled = false; + } + }); walletRow.querySelector('#connect')?.addEventListener('click', async ev => { const b = ev.currentTarget as HTMLButtonElement; b.disabled = true;