Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/src/lib/people.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,28 @@ async function ownerVia(client: PolkadotClient, username: string): Promise<strin
return r ? u8aToHex(decodeAddress(r)) : null;
}

type Bytes = Uint8Array | string | undefined;
const bytesToText = (u: Bytes) => (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<string | null> {
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<string | null> {
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".
Expand Down
36 changes: 33 additions & 3 deletions app/src/lib/signer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Speaker> {
/** 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<Speaker> {
const inside = isInsideContainerSync();
if (inside && !(await waitForHost())) throw new Error(`No hay canal con Polkadot App. Abre ${APP_DOTNS} desde la app.`);

Expand Down Expand Up @@ -97,6 +99,14 @@ export async function connectSpeaker(): Promise<Speaker> {
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);
Expand Down Expand Up @@ -126,6 +136,26 @@ export async function connectSpeaker(): Promise<Speaker> {
return speaker;
}

async function identityWithoutHostUsername(typed?: string): Promise<{ username: string; identity: NonNullable<typeof identity> } | 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.');
Expand Down
16 changes: 15 additions & 1 deletion app/src/views/presenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')}<div class="grow"><b>${esc(s.username ?? shortAddr(s.address))}</b>
<div class="muted" style="font-size:13px">${signerNote(s)}</div></div>`
<div class="muted" style="font-size:13px">${signerNote(s)}</div>
${s.kind === 'app' && !s.rehearsal ? `<div style="display:flex;gap:6px;margin-top:6px"><input class="input" id="typed-user" placeholder="tu-usuario.01" autocapitalize="off" spellcheck="false" style="flex:1" aria-label="Tu username de la Polkadot App" />
<button class="btn sm" id="use-typed">Usar mi username</button></div>` : ''}</div>`
: `${tag('idle')}<div class="grow">Wallet del speaker<div class="muted" style="font-size:13px">Firma el recibo al final</div></div>
<button class="btn sm primary" id="connect">Conectar</button>`;
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 = `<div class="error-box">${icon('warningCircle')}${esc((e as Error).message)}</div>`;
b.disabled = false;
}
});
walletRow.querySelector('#connect')?.addEventListener('click', async ev => {
const b = ev.currentTarget as HTMLButtonElement;
b.disabled = true;
Expand Down
Loading