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
89 changes: 74 additions & 15 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ import type {
DpnsUsernameEntry,
DpnsRegistrationResult,
IdentityPublicKeyInfo,
OwnedUsername,
E2EMockWindow,
AssetLockProofData,
} from './types.js';
Expand Down Expand Up @@ -430,8 +431,14 @@ function createE2EMockIdentityKeys(): IdentityPublicKeyInfo[] {
];
}

function createE2EMockOwnedUsernames(): string[] {
return ['mockname.dash', 'second-mockname.dash'];
function createE2EMockOwnedUsernames(): OwnedUsername[] {
// Both labels contain "l", which homograph-folds to "1" in normalizedLabel.
// Real names like these cannot be resolved from their display form, so the
// mock keeps the flow honest about carrying document ids around.
return [
{ username: 'mocklabel.dash', documentId: E2E_MOCK_IDENTITY_ID, ownerId: E2E_MOCK_IDENTITY_ID },
{ username: 'second-mocklabel.dash', documentId: E2E_MOCK_XFER_RECIPIENT_ID, ownerId: E2E_MOCK_IDENTITY_ID },
];
}

function createE2EMockDpnsAvailability(entries: DpnsUsernameEntry[]): DpnsUsernameEntry[] {
Expand Down Expand Up @@ -3416,7 +3423,7 @@ async function startManageUpdate() {
* known: the names it owns, and the network's protocol version.
*/
async function loadTransferContext(identityId: string): Promise<{
usernames: string[];
usernames: OwnedUsername[];
protocolVersion?: number;
}> {
const { listOwnedUsernames, getProtocolVersion } = await loadUsernameTransferModule();
Expand Down Expand Up @@ -3456,31 +3463,76 @@ async function startXferUnlockFromSeed(mnemonic: string) {

const candidates = deriveCandidateKeys(mnemonic, state.network);

const { discoverIdentityFromCandidates } = await loadUsernameTransferModule();
const discovered = await discoverIdentityFromCandidates(
const { discoverIdentitiesFromCandidates } = await loadUsernameTransferModule();
const discovered = await discoverIdentitiesFromCandidates(
candidates,
state.network,
(checked, total) => {
updateState(setXferDiscoveryStatus(state, `Searching for your identity (${checked}/${total})...`));
(checked: number, total: number) => {
updateState(setXferDiscoveryStatus(state, `Searching for your identities (${checked}/${total})...`));
}
);

if (!discovered) {
if (discovered.length === 0) {
updateState(setXferCredentialError(
state,
'No identity on this network uses a key from that seed phrase. Check you picked the right network, or use the Private Key tab to enter an identity ID directly.'
));
return;
}

updateState(setXferDiscoveryStatus(state, 'Identity found. Checking your keys...'));
updateState(setXferDiscoveryStatus(
state,
discovered.length === 1
? 'Identity found. Checking your keys...'
: `Found ${discovered.length} identities. Checking which own usernames...`
));

// A seed commonly controls several identities, and the first one found is not
// necessarily the one holding the name. Load each, then prefer one that
// actually owns a username.
const loaded: {
identityId: string;
selection: ReturnType<typeof selectTransferSigningKey>;
usernames: OwnedUsername[];
protocolVersion?: number;
}[] = [];

for (const { identityId } of discovered) {
const keys = await getIdentityPublicKeys(identityId, state.network);
const selection = selectTransferSigningKey(candidates, keys, state.network);
const context = selection.status === 'ok'
? await loadTransferContext(identityId)
: { usernames: [] as OwnedUsername[], protocolVersion: undefined };
loaded.push({ identityId, selection, ...context });
}

const usable = loaded.filter((entry) => entry.selection.status === 'ok');
if (usable.length === 0) {
// Report the most informative failure we saw rather than a generic one.
const ineligible = loaded.find((entry) => entry.selection.status === 'ineligible');
await applyXferKeySelection(
loaded[0].identityId,
ineligible?.selection ?? loaded[0].selection,
'That seed phrase does not control any active key on the identities it points to.'
);
return;
}

const chosen = usable.find((entry) => entry.usernames.length > 0) ?? usable[0];
const selection = chosen.selection;
if (selection.status !== 'ok') return; // narrowed by `usable` above

const keys = await getIdentityPublicKeys(discovered.identityId, state.network);
await applyXferKeySelection(
discovered.identityId,
selectTransferSigningKey(candidates, keys, state.network),
'That seed phrase does not control any active key on the identity it points to.'
);
updateState(setXferIdentityUnlocked(state, {
identityId: chosen.identityId,
privateKeyWif: selection.candidate.privateKeyWif,
keyId: selection.keyId,
securityLevel: selection.securityLevel,
usernames: chosen.usernames,
protocolVersion: chosen.protocolVersion,
otherIdentities: usable
.filter((entry) => entry.identityId !== chosen.identityId)
.map((entry) => entry.identityId),
}));
}

/**
Expand Down Expand Up @@ -3700,9 +3752,16 @@ async function startUsernameTransfer() {
return;
}

const selected = (state.xferOwnedUsernames || []).find((u) => u.username === username);
if (!selected) {
updateState(setXferResult(state, { success: false, error: `"${username}" is no longer in the list of names this identity owns` }));
return;
}

const { transferUsername } = await loadUsernameTransferModule();
const result = await transferUsername({
username,
documentId: selected.documentId,
identityId,
publicKeyId: keyId,
privateKeyWif,
Expand Down
11 changes: 11 additions & 0 deletions src/platform/dpns-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ describe('DPNS username helpers', () => {
expect(validateDpnsLabel('dash--user')).toEqual({ isValid: false, error: 'No consecutive hyphens allowed' });
});

it('folds l, i and o, so a display label is not its normalizedLabel', () => {
// This is why a username cannot be resolved back to its document from the
// name shown in the UI: the index is on normalizedLabel, but the display
// form uses the raw label. Any name containing l, i or o diverges.
expect(convertToHomographSafe('testfjdksla234123')).toBe('testfjdks1a234123');
expect(convertToHomographSafe('pastafaucettesting1234')).toBe('pastafaucettest1ng1234');
// A name with none of those characters round-trips, which is how a live
// transfer of "xfertest7pasta" passed while the bug was present.
expect(convertToHomographSafe('xfertest7pasta')).toBe('xfertest7pasta');
});

it('normalizes labels for homograph-safe contested-name checks', () => {
expect(convertToHomographSafe('Oil-Loom')).toBe('011-100m');
expect(isContestedUsername('dash')).toBe(true);
Expand Down
122 changes: 81 additions & 41 deletions src/platform/username-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { extractErrorMessage } from '../utils/errors.js';
import { loadSdkModule } from './sdkModule.js';
import type { DerivedCandidateKey } from './username-transfer-utils.js';
import type { UsernameTransferOutcome } from '../types.js';
import type { OwnedUsername, UsernameTransferOutcome } from '../types.js';

/**
* DPNS is a system data contract with the same ID on every network.
Expand Down Expand Up @@ -67,44 +67,70 @@ async function findIdentityIdByPublicKeyHash(
}

/**
* Walk derived candidates in order and return the first one that resolves to a
* registered identity. Candidates are pre-ordered cheapest-first, so this
* usually resolves on the first or second lookup.
* Find every identity the seed controls, in candidate order.
*
* This deliberately scans all candidates rather than stopping at the first hit:
* one seed routinely controls several identities, and the first one found is
* not necessarily the one holding the username the user wants to move.
*
* Throws if no lookup ever completed, so an unreachable network is not reported
* to the user as an unrecognised seed phrase.
*/
export async function discoverIdentityFromCandidates(
export async function discoverIdentitiesFromCandidates(
candidates: DerivedCandidateKey[],
network: string,
onProgress?: (checked: number, total: number) => void,
retryOptions?: RetryOptions
): Promise<DiscoveredIdentity | undefined> {
): Promise<DiscoveredIdentity[]> {
return withConnectedPlatformSdk(
network,
async (sdk) => {
// Scanning every candidate rather than stopping at the first hit means a
// fixed cost per unlock, so the lookups run a few at a time instead of
// one after another. The cap keeps this from opening a burst of
// connections against DAPI.
const CONCURRENCY = 4;
const results = new Array<Awaited<ReturnType<typeof findIdentityIdByPublicKeyHash>>>(
candidates.length
);
let checked = 0;

for (let start = 0; start < candidates.length; start += CONCURRENCY) {
const batch = candidates.slice(start, start + CONCURRENCY);
await Promise.all(
batch.map(async (candidate, offset) => {
results[start + offset] = await findIdentityIdByPublicKeyHash(sdk, candidate.publicKeyHash);
onProgress?.(++checked, candidates.length);
})
);
}

// Fold in candidate order so the identity reached by the earliest
// derivation path wins, regardless of which lookup resolved first.
const found: DiscoveredIdentity[] = [];
const seen = new Set<string>();
let anyAnswered = false;
let lastError: unknown;

for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
onProgress?.(i + 1, candidates.length);

const result = await findIdentityIdByPublicKeyHash(sdk, candidate.publicKeyHash);
for (let k = 0; k < candidates.length; k++) {
const result = results[k];
if (result.identityId) {
return { identityId: result.identityId, candidate };
}
if (result.error === undefined) {
anyAnswered = true;
if (!seen.has(result.identityId)) {
seen.add(result.identityId);
found.push({ identityId: result.identityId, candidate: candidates[k] });
}
} else if (result.error === undefined) {
anyAnswered = true;
} else {
lastError = result.error;
}
}

if (!anyAnswered && lastError !== undefined) {
if (found.length === 0 && !anyAnswered && lastError !== undefined) {
throw lastError;
}
return undefined;
return found;
},
retryOptions
);
Expand All @@ -113,22 +139,43 @@ export async function discoverIdentityFromCandidates(
/**
* List the usernames an identity owns.
*
* Note this queries by `records.identity`, not `$ownerId` — the two are kept in
* sync by Platform on transfer, but the caller still re-checks `ownerId` on the
* document before signing rather than trusting this listing.
* Queries by `records.identity` because that is the indexed field — `$ownerId`
* is not indexed on the DPNS `domain` type, so Drive rejects a where clause on
* it. Platform keeps the two in sync on transfer, and the caller re-checks
* `ownerId` on the document before signing regardless.
*/
export async function listOwnedUsernames(
identityId: string,
network: string,
retryOptions?: RetryOptions
): Promise<string[]> {
): Promise<OwnedUsername[]> {
return withConnectedPlatformSdk(
network,
(sdk) =>
withRetry(
() => sdk.dpns.usernames({ identityId, limit: USERNAME_LIST_LIMIT }),
async (sdk) => {
const documents = await withRetry(
() => sdk.documents.query({
dataContractId: DPNS_CONTRACT_ID,
documentTypeName: DPNS_DOCUMENT_TYPE,
where: [['records.identity', '==', identityId]],
limit: USERNAME_LIST_LIMIT,
}),
retryOptions
),
);

const owned: OwnedUsername[] = [];
for (const document of documents.values()) {
if (!document) continue;
const label = document.properties?.label;
const parent = document.properties?.normalizedParentDomainName;
if (typeof label !== 'string' || typeof parent !== 'string') continue;
owned.push({
username: `${label}.${parent}`,
documentId: document.id.toString(),
ownerId: document.ownerId.toString(),
});
}
return owned;
},
retryOptions
);
}
Expand Down Expand Up @@ -212,7 +259,10 @@ async function readDomainOwnership(
}

export interface TransferUsernameParams {
/** Display name, used only for messages. */
username: string;
/** The DPNS domain document that backs the name. */
documentId: string;
identityId: string;
publicKeyId: number;
privateKeyWif: string;
Expand All @@ -230,7 +280,7 @@ export async function transferUsername(
params: TransferUsernameParams,
retryOptions?: RetryOptions
): Promise<UsernameTransferOutcome> {
const { username, identityId, publicKeyId, privateKeyWif, recipientId, network } = params;
const { username, documentId, identityId, publicKeyId, privateKeyWif, recipientId, network } = params;

if (identityId === recipientId) {
return { success: false, error: 'Cannot transfer a username to the identity that already owns it' };
Expand All @@ -239,29 +289,19 @@ export async function transferUsername(
return withConnectedPlatformSdk(
network,
async (sdk) => {
const info = await withRetry(() => sdk.dpns.getUsernameByName(username), retryOptions);
if (!info) {
return { success: false, error: `Username "${username}" was not found on ${network}` };
}

const documentId = info.documentId.toString();
if (info.identityId.toString() !== identityId) {
return {
success: false,
error: `"${username}" is owned by ${info.identityId.toString()}, not ${identityId}`,
};
}

// The document id comes from the listing rather than a name lookup:
// dpns.getUsernameByName matches on the homograph-folded normalizedLabel,
// so any name containing l, i or o would not resolve from its display form.
const document = await withRetry(
() => sdk.documents.get(DPNS_CONTRACT_ID, DPNS_DOCUMENT_TYPE, documentId),
retryOptions
);
if (!document) {
return { success: false, error: `Domain document ${documentId} could not be fetched` };
return { success: false, error: `The document behind "${username}" could not be fetched` };
}

// Re-check ownership on the document itself. `getUsernameByName` reports
// the owner, but this is the object we are about to sign over.
// Re-check ownership on the document we are about to sign over, rather
// than trusting the listing it came from.
if (document.ownerId.toString() !== identityId) {
return {
success: false,
Expand Down
15 changes: 14 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ export interface IdentityPublicKeyInfo {
*/
export type UsernameTransferCredentialSource = 'seed' | 'key';

/**
* A username an identity owns, paired with the DPNS document that backs it.
* The document id is carried so the transfer never has to resolve a display
* name back to a document.
*/
export interface OwnedUsername {
username: string;
documentId: string;
ownerId: string;
}

/**
* Outcome of a username transfer attempt.
*/
Expand Down Expand Up @@ -413,7 +424,9 @@ export interface BridgeState {
/** Transfer: validated signing key */
xferSigningKeyInfo?: { keyId: number; securityLevel: number };
/** Transfer: usernames owned by the source identity */
xferOwnedUsernames?: string[];
xferOwnedUsernames?: OwnedUsername[];
/** Transfer: other identities the seed controls, if it found more than one */
xferOtherIdentities?: string[];
/** Transfer: the username selected for transfer */
xferSelectedUsername?: string;
/** Transfer: destination identity ID */
Expand Down
Loading
Loading