From 626d042c977896fda89ab347b419cdb142b86455 Mon Sep 17 00:00:00 2001 From: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:30:39 +0000 Subject: [PATCH 1/4] feat: import existing Platform identities --- .../migrations/0013_imported_identity_keys.ts | 26 ++ src/main/src/WalletBackend.ts | 13 +- src/main/src/api/wallet/importIdentity.ts | 17 ++ src/main/src/database/IdentityDAO.ts | 15 +- src/main/src/database/IdentityKeyDAO.ts | 54 ++++ src/main/src/database/WalletDAO.ts | 4 + .../src/services/PlatformAddressService.ts | 156 +++++++++-- src/main/src/services/WalletService.ts | 4 +- src/main/src/types/Identity.ts | 2 + src/main/src/types/IdentityImportResult.ts | 5 + src/main/src/utils/identityKeys.ts | 17 +- src/main/src/utils/index.ts | 20 +- src/preload/definitions.ts | 1 + src/preload/index.d.ts | 1 + src/renderer/src/api/index.ts | 6 +- src/renderer/src/api/types.ts | 6 + .../src/components/modal/ImportIdentity.tsx | 259 ++++++++++++++++++ .../pages/identities/IdentityCard.tsx | 5 + .../src/components/pages/identities/Page.tsx | 32 +++ src/renderer/src/hooks/useIdentities.tsx | 1 + tests/unit/identityImport.test.ts | 189 +++++++++++++ tests/unit/identityKeyStorage.test.ts | 75 +++++ tests/unit/identityKeys.test.ts | 18 +- 23 files changed, 895 insertions(+), 31 deletions(-) create mode 100644 src/main/migrations/0013_imported_identity_keys.ts create mode 100644 src/main/src/api/wallet/importIdentity.ts create mode 100644 src/main/src/database/IdentityKeyDAO.ts create mode 100644 src/main/src/types/IdentityImportResult.ts create mode 100644 src/renderer/src/components/modal/ImportIdentity.tsx create mode 100644 tests/unit/identityImport.test.ts create mode 100644 tests/unit/identityKeyStorage.test.ts diff --git a/src/main/migrations/0013_imported_identity_keys.ts b/src/main/migrations/0013_imported_identity_keys.ts new file mode 100644 index 00000000..d070af17 --- /dev/null +++ b/src/main/migrations/0013_imported_identity_keys.ts @@ -0,0 +1,26 @@ +import type {Knex} from 'knex' + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('identities', table => { + table.boolean('is_imported').notNullable().defaultTo(false) + }) + + await knex.schema.createTable('identity_keys', table => { + table.increments('id').primary() + table.text('wallet_id').notNullable().references('wallet_id').inTable('wallet') + table.text('identity_identifier').notNullable() + table.integer('key_id').notNullable() + table.text('public_key_hash').notNullable() + table.text('encrypted_private_key').notNullable() + + table.unique(['wallet_id', 'identity_identifier', 'key_id']) + table.index(['wallet_id', 'identity_identifier'], 'identity_keys_wallet_identity_idx') + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists('identity_keys') + await knex.schema.alterTable('identities', table => { + table.dropColumn('is_imported') + }) +} diff --git a/src/main/src/WalletBackend.ts b/src/main/src/WalletBackend.ts index a54a56c9..435f3d06 100644 --- a/src/main/src/WalletBackend.ts +++ b/src/main/src/WalletBackend.ts @@ -7,6 +7,7 @@ import { ipcMain } from 'electron' import { WalletDAO } from './database/WalletDAO' import { AddressDAO } from './database/AddressDAO' import { IdentityDAO } from './database/IdentityDAO' +import { IdentityKeyDAO } from './database/IdentityKeyDAO' import { TransactionDAO } from './database/TransactionDAO' import { ContactDAO } from './database/ContactDAO' import { WalletService } from './services/WalletService' @@ -42,6 +43,7 @@ import {SendIdentityCreditsHandler} from "./api/wallet/sendIdentityCredits"; import {TransferIdentityCreditsHandler} from "./api/wallet/transferIdentityCredits"; import {WithdrawIdentityCreditsHandler} from "./api/wallet/withdrawIdentityCredits"; import {CreateIdentityFromAddressesHandler} from "./api/wallet/createIdentityFromAddresses"; +import {ImportIdentityHandler} from "./api/wallet/importIdentity"; import {StartAssetLockFundingHandler} from "./api/wallet/startAssetLockFunding"; import {GetAssetLockFundingStateHandler} from "./api/wallet/getAssetLockFundingState"; import {ResumeAssetLockFundingHandler} from "./api/wallet/resumeAssetLockFunding"; @@ -140,6 +142,7 @@ export class WalletBackend { ipcMain.handle('transferIdentityCredits', new TransferIdentityCreditsHandler(this.platformAddressService).handle) ipcMain.handle('withdrawIdentityCredits', new WithdrawIdentityCreditsHandler(this.platformAddressService).handle) ipcMain.handle('createIdentityFromAddresses', new CreateIdentityFromAddressesHandler(this.platformAddressService).handle) + ipcMain.handle('importIdentity', new ImportIdentityHandler(this.platformAddressService).handle) ipcMain.handle('startAssetLockFunding', new StartAssetLockFundingHandler(this.assetLockService).handle) ipcMain.handle('getAssetLockFundingState', new GetAssetLockFundingStateHandler(this.assetLockService).handle) ipcMain.handle('resumeAssetLockFunding', new ResumeAssetLockFundingHandler(this.assetLockService).handle) @@ -203,10 +206,18 @@ export class WalletBackend { this.ratesService = new RatesService() this.contactService = new ContactService(contactDAO) const shieldedAddressDAO = new ShieldedAddressDAO(knex) + const identityKeyDAO = new IdentityKeyDAO(knex) this.identityRegistrationService = new IdentityRegistrationService(sdkProvider) this.shieldedService = new ShieldedService(sdkProvider, walletDAO, identityDAO, new ShieldedNoteDAO(knex), shieldedAddressDAO, this.identityRegistrationService) this.walletService = new WalletService(walletDAO, addressDAO, identityDAO, transactionDAO, this.applicationService, this.walletSyncService, sdkProvider, calibratedIterations, this.shieldedService) - this.platformAddressService = new PlatformAddressService(walletDAO, identityDAO, sdkProvider, this.shieldedService) + this.platformAddressService = new PlatformAddressService( + walletDAO, + identityDAO, + identityKeyDAO, + sdkProvider, + this.shieldedService, + calibratedIterations, + ) this.assetLockService = new AssetLockService(walletDAO, identityDAO, new AssetLockDAO(knex), this.walletService, this.shieldedService, sdkProvider, this.identityRegistrationService) this.sdkProvider = sdkProvider this.walletDAO = walletDAO diff --git a/src/main/src/api/wallet/importIdentity.ts b/src/main/src/api/wallet/importIdentity.ts new file mode 100644 index 00000000..2e2ed2da --- /dev/null +++ b/src/main/src/api/wallet/importIdentity.ts @@ -0,0 +1,17 @@ +import {IpcMainInvokeEvent} from 'electron/utility' +import {PlatformAddressService} from '../../services/PlatformAddressService' +import {IdentityImportResult} from '../../types/IdentityImportResult' + +export class ImportIdentityHandler { + constructor(private readonly platformAddressService: PlatformAddressService) {} + + handle = async ( + _event: IpcMainInvokeEvent, + walletId: string, + identityIdentifier: string, + privateKeys: string[], + password: string, + ): Promise => { + return this.platformAddressService.importIdentity(walletId, identityIdentifier, privateKeys, password) + } +} diff --git a/src/main/src/database/IdentityDAO.ts b/src/main/src/database/IdentityDAO.ts index 878e62bf..e28c5d0f 100644 --- a/src/main/src/database/IdentityDAO.ts +++ b/src/main/src/database/IdentityDAO.ts @@ -1,8 +1,15 @@ import type { Knex } from 'knex' import { Identity } from '../types/Identity' -function identityFromRow ({ wallet_id, identity_index, identifier, derivation_path, asset_lock_txid }): Identity { - return { walletId: wallet_id, identityIndex: identity_index, identifier, derivationPath: derivation_path, assetLockTxid: asset_lock_txid ?? null } +function identityFromRow ({ wallet_id, identity_index, identifier, derivation_path, asset_lock_txid, is_imported }): Identity { + return { + walletId: wallet_id, + identityIndex: identity_index, + identifier, + derivationPath: derivation_path, + assetLockTxid: asset_lock_txid ?? null, + isImported: Boolean(is_imported), + } } export class IdentityDAO { @@ -25,7 +32,7 @@ export class IdentityDAO { getIdentitiesByWalletId = async (walletId: string): Promise => { const rows = await this.knex('identities') - .select('wallet_id', 'identity_index', 'identifier', 'derivation_path', 'asset_lock_txid') + .select('wallet_id', 'identity_index', 'identifier', 'derivation_path', 'asset_lock_txid', 'is_imported') .where('wallet_id', walletId) .orderBy('identity_index', 'asc') @@ -34,7 +41,7 @@ export class IdentityDAO { getByIdentifier = async (walletId: string, identifier: string): Promise => { const row = await this.knex('identities') - .select('wallet_id', 'identity_index', 'identifier', 'derivation_path', 'asset_lock_txid') + .select('wallet_id', 'identity_index', 'identifier', 'derivation_path', 'asset_lock_txid', 'is_imported') .where({ wallet_id: walletId, identifier }) .first() diff --git a/src/main/src/database/IdentityKeyDAO.ts b/src/main/src/database/IdentityKeyDAO.ts new file mode 100644 index 00000000..f16b06de --- /dev/null +++ b/src/main/src/database/IdentityKeyDAO.ts @@ -0,0 +1,54 @@ +import type {Knex} from 'knex' +import type {Identity} from '../types/Identity' + +export interface ImportedIdentityKey { + walletId: string + identityIdentifier: string + keyId: number + publicKeyHash: string + encryptedPrivateKey: string +} + +function keyFromRow({wallet_id, identity_identifier, key_id, public_key_hash, encrypted_private_key}): ImportedIdentityKey { + return { + walletId: wallet_id, + identityIdentifier: identity_identifier, + keyId: key_id, + publicKeyHash: public_key_hash, + encryptedPrivateKey: encrypted_private_key, + } +} + +export class IdentityKeyDAO { + constructor(private readonly knex: Knex) {} + + async getByIdentity(walletId: string, identityIdentifier: string): Promise { + const rows = await this.knex('identity_keys') + .select('wallet_id', 'identity_identifier', 'key_id', 'public_key_hash', 'encrypted_private_key') + .where({wallet_id: walletId, identity_identifier: identityIdentifier}) + .orderBy('key_id', 'asc') + + return rows.map(keyFromRow) + } + + async insertImportedIdentity(identity: Identity, keys: ImportedIdentityKey[]): Promise { + await this.knex.transaction(async trx => { + await trx('identities').insert({ + wallet_id: identity.walletId, + identity_index: identity.identityIndex, + derivation_path: identity.derivationPath, + identifier: identity.identifier, + asset_lock_txid: null, + is_imported: true, + }) + + await trx('identity_keys').insert(keys.map(key => ({ + wallet_id: key.walletId, + identity_identifier: key.identityIdentifier, + key_id: key.keyId, + public_key_hash: key.publicKeyHash, + encrypted_private_key: key.encryptedPrivateKey, + }))) + }) + } +} diff --git a/src/main/src/database/WalletDAO.ts b/src/main/src/database/WalletDAO.ts index fcd16b6c..92373f20 100644 --- a/src/main/src/database/WalletDAO.ts +++ b/src/main/src/database/WalletDAO.ts @@ -198,6 +198,10 @@ export class WalletDAO { .first() const wasSelected = Boolean(target?.selected) + await this.knex('identity_keys') + .delete() + .where('wallet_id', walletId) + await this.knex('identities') .delete() .where('wallet_id', walletId) diff --git a/src/main/src/services/PlatformAddressService.ts b/src/main/src/services/PlatformAddressService.ts index 54853187..ec1a3f9c 100644 --- a/src/main/src/services/PlatformAddressService.ts +++ b/src/main/src/services/PlatformAddressService.ts @@ -1,5 +1,5 @@ import type {DashPlatformSDK} from 'dash-platform-sdk' -import {SdkProvider} from '../providers/SdkProvider' +import type {SdkProvider} from '../providers/SdkProvider' import { InputAddressWASM, OutputAddressWASM, @@ -13,19 +13,21 @@ import { PrivateKeyWASM, IdentityPublicKeyWASM, } from 'dash-platform-sdk/types.js' -import {WalletDAO} from '../database/WalletDAO' -import {ShieldedService} from './ShieldedService' -import {IdentityDAO} from '../database/IdentityDAO' +import type {WalletDAO} from '../database/WalletDAO' +import type {ShieldedService} from './ShieldedService' +import type {IdentityDAO} from '../database/IdentityDAO' +import type {IdentityKeyDAO, ImportedIdentityKey} from '../database/IdentityKeyDAO' import {Network} from '../types' import {Wallet} from '../types/Wallet' import {Identity} from '../types/Identity' import {PlatformAddressEntry} from '../types/PlatformAddress' import {PlatformSendResult} from '../types/PlatformSendResult' import {IdentityCreateResult} from '../types/IdentityCreateResult' +import {IdentityImportResult} from '../types/IdentityImportResult' import {ShieldResult} from '../types/ShieldResult' -import {decryptMnemonic} from '../utils' +import {decryptMnemonic, decryptSecret, encryptSecret} from '../utils' import {coreAddressToScript} from '../utils/coreScript' -import {matchIdentityKey, DerivedKeyHash} from '../utils/identityKeys' +import {matchIdentityKey, DerivedKeyHash, parseIdentityPrivateKey} from '../utils/identityKeys' import { PlatformSourceCandidate, selectPlatformSource, @@ -53,14 +55,25 @@ const COIN_TYPE: Record = {mainnet: 5, testnet: 1} export class PlatformAddressService { private walletDAO: WalletDAO private identityDAO: IdentityDAO + private identityKeyDAO: IdentityKeyDAO private sdkProvider: SdkProvider private shieldedService: ShieldedService - - constructor(walletDAO: WalletDAO, identityDAO: IdentityDAO, sdkProvider: SdkProvider, shieldedService: ShieldedService) { + private pbkdf2Iterations: number + + constructor( + walletDAO: WalletDAO, + identityDAO: IdentityDAO, + identityKeyDAO: IdentityKeyDAO, + sdkProvider: SdkProvider, + shieldedService: ShieldedService, + pbkdf2Iterations: number, + ) { this.walletDAO = walletDAO this.identityDAO = identityDAO + this.identityKeyDAO = identityKeyDAO this.sdkProvider = sdkProvider this.shieldedService = shieldedService + this.pbkdf2Iterations = pbkdf2Iterations } private platformSDK(network: Network): DashPlatformSDK { @@ -97,6 +110,96 @@ export class PlatformAddressService { return this.getPlatformAddresses(walletId) } + async importIdentity( + walletId: string, + identityIdentifier: string, + privateKeyValues: string[], + password: string, + ): Promise { + const wallet = await this.requireWallet(walletId) + + let mnemonic: string + try { + mnemonic = decryptMnemonic(wallet.encryptedMnemonic, password) + } catch { + throw new Error('Invalid wallet password') + } + + const identifier = identityIdentifier.trim() + if (identifier.length === 0) { + throw new Error('Identity identifier is required') + } + + if (await this.identityDAO.getByIdentifier(walletId, identifier) != null) { + throw new Error('Identity is already in this wallet') + } + + const values = privateKeyValues.map(value => value.trim()).filter(Boolean) + if (values.length === 0) { + throw new Error('At least one private key is required') + } + + let identityPublicKeys: IdentityPublicKeyWASM[] + try { + await this.platformSDK(wallet.network).identities.getIdentityByIdentifier(identifier) + identityPublicKeys = await this.platformSDK(wallet.network).identities.getIdentityPublicKeys(identifier) + } catch { + throw new Error('Identity was not found on the selected network') + } + + const matched = values.map(value => { + let privateKey: PrivateKeyWASM + try { + privateKey = parseIdentityPrivateKey(value, wallet.network) + } catch { + throw new Error('One or more private keys are not valid hex or WIF keys') + } + + const publicKeyHash = privateKey.getPublicKeyHash() + const publicKey = identityPublicKeys.find(key => + key.getPublicKeyHash().toLowerCase() === publicKeyHash.toLowerCase()) + + if (publicKey == null) { + throw new Error('One or more private keys do not belong to this identity') + } + + return {privateKey, publicKey, publicKeyHash} + }) + + const keyIds = matched.map(({publicKey}) => publicKey.keyId) + if (new Set(keyIds).size !== keyIds.length) { + throw new Error('The same identity key was entered more than once') + } + + const existing = await this.identityDAO.getIdentitiesByWalletId(walletId) + const identityIndex = existing.reduce( + (next, identity) => identity.isImported ? Math.min(next, identity.identityIndex - 1) : next, + -1, + ) + const keyEncryptionSecret = mnemonic.trim().replace(/\s+/g, ' ') + const keys: ImportedIdentityKey[] = matched.map(({privateKey, publicKey, publicKeyHash}) => ({ + walletId, + identityIdentifier: identifier, + keyId: publicKey.keyId, + publicKeyHash, + encryptedPrivateKey: encryptSecret(privateKey.hex().toLowerCase(), keyEncryptionSecret, this.pbkdf2Iterations), + })) + + await this.identityKeyDAO.insertImportedIdentity({ + walletId, + identityIndex, + derivationPath: '', + identifier, + isImported: true, + }, keys) + + return { + identifier, + importedKeyIds: keyIds.sort((a, b) => a - b), + hasTransferKey: matched.some(({publicKey}) => publicKey.purpose.toUpperCase() === 'TRANSFER'), + } + } + async sendPlatformTransfer( walletId: string, fromPlatformAddress: string, @@ -165,7 +268,7 @@ export class PlatformAddressService { } } - const {wallet, seed} = await this.unlock(walletId, password) + const {wallet, seed, mnemonic} = await this.unlock(walletId, password) const network = wallet.network const identities = await this.identityDAO.getIdentitiesByWalletId(walletId) @@ -183,7 +286,7 @@ export class PlatformAddressService { } const hdKey = this.platformSDK(network).keyPair.seedToHdKey(seed, network) - const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network) + const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network, mnemonic) const nonce = await this.platformSDK(network).identities.getIdentityNonce(identityIdentifier) + 1n @@ -226,7 +329,7 @@ export class PlatformAddressService { throw new Error('Recipient identity must be different from the source identity') } - const {wallet, seed} = await this.unlock(walletId, password) + const {wallet, seed, mnemonic} = await this.unlock(walletId, password) const network = wallet.network const identities = await this.identityDAO.getIdentitiesByWalletId(walletId) @@ -241,7 +344,7 @@ export class PlatformAddressService { } const hdKey = this.platformSDK(network).keyPair.seedToHdKey(seed, network) - const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network) + const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network, mnemonic) const identityNonce = await this.platformSDK(network).identities.getIdentityNonce(fromIdentityIdentifier) + 1n @@ -498,7 +601,7 @@ export class PlatformAddressService { throw new Error('Withdrawal amount must be greater than zero') } - const {wallet, seed} = await this.unlock(walletId, password) + const {wallet, seed, mnemonic} = await this.unlock(walletId, password) const network = wallet.network const outputScript = coreAddressToScript(toCoreAddress, network) @@ -515,7 +618,7 @@ export class PlatformAddressService { } const hdKey = this.platformSDK(network).keyPair.seedToHdKey(seed, network) - const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network) + const {privateKey, publicKey} = await this.resolveIdentitySigningKey(identity, hdKey, network, mnemonic) const identityNonce = await this.platformSDK(network).identities.getIdentityNonce(identityIdentifier) + 1n @@ -591,7 +694,7 @@ export class PlatformAddressService { // Decrypts the mnemonic, derives the seed, and backfills the persisted // DIP-17 account xpub for wallets created before the column existed. - private async unlock(walletId: string, password: string): Promise<{wallet: Wallet; seed: Uint8Array; xpub: string}> { + private async unlock(walletId: string, password: string): Promise<{wallet: Wallet; seed: Uint8Array; xpub: string; mnemonic: string}> { const wallet = await this.requireWallet(walletId) let mnemonic: string @@ -609,7 +712,7 @@ export class PlatformAddressService { await this.walletDAO.setPlatformXpub(walletId, xpub) } - return {wallet, seed, xpub} + return {wallet, seed, xpub, mnemonic} } private async extendPlatformWindow(walletId: string, xpub: string, network: Network): Promise { @@ -709,9 +812,30 @@ export class PlatformAddressService { identity: Identity, hdKey: ReturnType, network: Network, + mnemonic: string, ): Promise<{privateKey: PrivateKeyWASM; publicKey: IdentityPublicKeyWASM}> { const identityKeys = await this.platformSDK(network).identities.getIdentityPublicKeys(identity.identifier) + const importedKeys = await this.identityKeyDAO.getByIdentity(identity.walletId, identity.identifier) + for (const publicKey of identityKeys + .filter(key => key.purpose.toUpperCase() === 'TRANSFER') + .sort((a, b) => a.keyId - b.keyId)) { + const imported = importedKeys.find(key => key.keyId === publicKey.keyId) + if (imported == null) continue + + let privateKey: PrivateKeyWASM + try { + const keyEncryptionSecret = mnemonic.trim().replace(/\s+/g, ' ') + privateKey = PrivateKeyWASM.fromHex(decryptSecret(imported.encryptedPrivateKey, keyEncryptionSecret), network) + } catch { + throw new Error('Could not decrypt the imported identity key') + } + + if (privateKey.getPublicKeyHash().toLowerCase() === publicKey.getPublicKeyHash().toLowerCase()) { + return {privateKey, publicKey} + } + } + const derivedKeys: Array<{keyIndex: number; privateKey: PrivateKeyWASM}> = [] const derivedHashes: DerivedKeyHash[] = [] for (let keyIndex = 0; keyIndex < IDENTITY_KEY_LOOKAHEAD; keyIndex++) { diff --git a/src/main/src/services/WalletService.ts b/src/main/src/services/WalletService.ts index 85b40bfa..66104332 100644 --- a/src/main/src/services/WalletService.ts +++ b/src/main/src/services/WalletService.ts @@ -825,7 +825,8 @@ export class WalletService { usdAmount: '0.0' }, derivationPath: entry.derivationPath, - assetLockTxid: entry.assetLockTxid ?? null + assetLockTxid: entry.assetLockTxid ?? null, + isImported: entry.isImported ?? false, }) } catch { // identity not registered on platform yet, skip @@ -851,4 +852,3 @@ export class WalletService { return this.sdkProvider.getPlatformSDK(wallet.network).identities.getIdentityNonce(identifier) } } - diff --git a/src/main/src/types/Identity.ts b/src/main/src/types/Identity.ts index 75f9a0bd..58a40a09 100644 --- a/src/main/src/types/Identity.ts +++ b/src/main/src/types/Identity.ts @@ -6,6 +6,7 @@ export interface Identity { derivationPath: string identifier: string assetLockTxid?: string | null + isImported?: boolean } export interface IdentityInfo { @@ -15,4 +16,5 @@ export interface IdentityInfo { balance: AmountWithUsd derivationPath: string assetLockTxid: string | null + isImported: boolean } diff --git a/src/main/src/types/IdentityImportResult.ts b/src/main/src/types/IdentityImportResult.ts new file mode 100644 index 00000000..5321c3a7 --- /dev/null +++ b/src/main/src/types/IdentityImportResult.ts @@ -0,0 +1,5 @@ +export interface IdentityImportResult { + identifier: string + importedKeyIds: number[] + hasTransferKey: boolean +} diff --git a/src/main/src/utils/identityKeys.ts b/src/main/src/utils/identityKeys.ts index 33704f04..e72da3c7 100644 --- a/src/main/src/utils/identityKeys.ts +++ b/src/main/src/utils/identityKeys.ts @@ -1,4 +1,5 @@ -import {KeyType, Purpose, SecurityLevel} from 'dash-platform-sdk/types.js' +import {KeyType, PrivateKeyWASM, Purpose, SecurityLevel} from 'dash-platform-sdk/types.js' +import {Network} from '../types' // Protocol limits IdentityCreateTransition to 6 public keys. AUTH MEDIUM is // dropped (added later via IdentityUpdateTransition if needed); MASTER / @@ -44,3 +45,17 @@ export function matchIdentityKey( return null } + +export function parseIdentityPrivateKey(value: string, network: Network): PrivateKeyWASM { + const trimmed = value.trim() + + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return PrivateKeyWASM.fromHex(trimmed, network) + } + + if (/^[1-9A-HJ-NP-Za-km-z]{51,52}$/.test(trimmed)) { + return PrivateKeyWASM.fromWIF(trimmed) + } + + throw new Error('Private keys must be 64-character hex or WIF') +} diff --git a/src/main/src/utils/index.ts b/src/main/src/utils/index.ts index 240765c4..007b7f2d 100644 --- a/src/main/src/utils/index.ts +++ b/src/main/src/utils/index.ts @@ -16,6 +16,7 @@ import * as migration0009 from '../../migrations/0009_identity_asset_lock' import * as migration0010 from '../../migrations/0010_shielded_addresses' import * as migration0011 from '../../migrations/0011_shielded_note_ciphertext' import * as migration0012 from '../../migrations/0012_wallet_sync_initial_scan' +import * as migration0013 from '../../migrations/0013_imported_identity_keys' const migrations = [ { name: '0000_init.ts', migration: migration0000 }, @@ -31,6 +32,7 @@ const migrations = [ { name: '0010_shielded_addresses.ts', migration: migration0010 }, { name: '0011_shielded_note_ciphertext.ts', migration: migration0011 }, { name: '0012_wallet_sync_initial_scan.ts', migration: migration0012 }, + { name: '0013_imported_identity_keys.ts', migration: migration0013 }, ] const inlineMigrationSource = { @@ -43,7 +45,7 @@ import {Address} from "../types/Address"; import {TransactionStatus} from "../enums/TransactionStatus"; import {Transaction} from "../types/Transaction"; import {IdentityWASM, PrivateKeyWASM} from "dash-platform-sdk/types.js"; -import {DashPlatformSDK} from "dash-platform-sdk"; +import type {DashPlatformSDK} from "dash-platform-sdk"; import {Network} from "../types"; import {createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes} from "node:crypto"; @@ -80,13 +82,13 @@ export function deriveKeyFromPassword(password: string, iterations: number, salt // AES-256-GCM with a PBKDF2-derived key. Layout written to storage: // iv(12) | salt(32) | iterations(u32 BE) | ciphertext | tag(16) // Returns a hex-encoded blob ready for SQL persistence. -export function encryptMnemonic(mnemonic: string, password: string, iterations: number): string { +export function encryptSecret(secret: string, password: string, iterations: number): string { const salt = randomBytes(32) const passwordKey = deriveKeyFromPassword(password, iterations, salt) const iv = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', passwordKey, iv) - const ciphertext = Buffer.concat([cipher.update(mnemonic, 'utf8'), cipher.final()]) + const ciphertext = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]) const tag = cipher.getAuthTag() const iterBuf = Buffer.alloc(4) @@ -95,10 +97,10 @@ export function encryptMnemonic(mnemonic: string, password: string, iterations: return Buffer.concat([iv, salt, iterBuf, ciphertext, tag]).toString('hex') } -// Reverse of encryptMnemonic. Throws if the password is wrong (GCM auth +// Reverse of encryptSecret. Throws if the password is wrong (GCM auth // tag mismatch) or the blob is shorter than the fixed header — callers // translate to user-facing errors. -export function decryptMnemonic(encryptedHex: string, password: string): string { +export function decryptSecret(encryptedHex: string, password: string): string { const data = Buffer.from(encryptedHex, 'hex') const iv = data.slice(0, 12) @@ -116,6 +118,14 @@ export function decryptMnemonic(encryptedHex: string, password: string): string return decrypted.toString('utf8') } +export function encryptMnemonic(mnemonic: string, password: string, iterations: number): string { + return encryptSecret(mnemonic, password, iterations) +} + +export function decryptMnemonic(encryptedHex: string, password: string): string { + return decryptSecret(encryptedHex, password) +} + export function getKnex (path?: string): Knex { return knex({ client: 'sqlite3', diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 9c405631..96096950 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -17,6 +17,7 @@ export const apiDefinitions = (ipcRenderer) => ({ getBalance: (address: string | string[], network: string) => ipcRenderer.invoke('getBalance', address, network), getWalletBalance: (walletId: string) => ipcRenderer.invoke('getWalletBalance', walletId), getIdentities: (walletId: string) => ipcRenderer.invoke('getIdentities', walletId), + importIdentity: (walletId: string, identityIdentifier: string, privateKeys: string[], password: string) => ipcRenderer.invoke('importIdentity', walletId, identityIdentifier, privateKeys, password), getIdentityBalance: (identifier: string): Promise => ipcRenderer.invoke('getIdentityBalance', identifier), getIdentityNonce: (identifier: string): Promise => ipcRenderer.invoke('getIdentityNonce', identifier), getPlatformAddresses: (walletId: string) => ipcRenderer.invoke('getPlatformAddresses', walletId), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index fc43c898..462de765 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -20,6 +20,7 @@ declare global { getBlockByHash: (hash: string, network: string) => Promise getBalance: (address: string | string[], network: string) => Promise getIdentities: (walletId: string) => Promise + importIdentity: (walletId: string, identityIdentifier: string, privateKeys: string[], password: string) => Promise<{ identifier: string; importedKeyIds: number[]; hasTransferKey: boolean }> getIdentityBalance: (identifier: string) => Promise getIdentityNonce: (identifier: string) => Promise getPlatformAddresses: (walletId: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 4d34cfa6..b7bad650 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,4 +1,4 @@ -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, ExchangeRatesResult, IdentityCreateResult, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, QueryStatus, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, ExchangeRatesResult, IdentityCreateResult, IdentityImportResult, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, QueryStatus, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, TxLockStatus } from './types' export class API { private static get api() { @@ -69,6 +69,10 @@ export class API { return this.api.getIdentities(walletId) } + static async importIdentity(walletId: string, identityIdentifier: string, privateKeys: string[], password: string): Promise { + return this.api.importIdentity(walletId, identityIdentifier, privateKeys, password) + } + static async getPlatformAddresses(walletId: string): Promise { return this.api.getPlatformAddresses(walletId) as Promise } diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index e69ba56b..0efc89d6 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -163,6 +163,12 @@ export interface IdentityCreateResult { fromAddress: string } +export interface IdentityImportResult { + identifier: string + importedKeyIds: number[] + hasTransferKey: boolean +} + export interface ShieldResult { stHash: string amountCredits: string diff --git a/src/renderer/src/components/modal/ImportIdentity.tsx b/src/renderer/src/components/modal/ImportIdentity.tsx new file mode 100644 index 00000000..79cf5152 --- /dev/null +++ b/src/renderer/src/components/modal/ImportIdentity.tsx @@ -0,0 +1,259 @@ +import {useEffect, useState} from 'react' +import {createPortal} from 'react-dom' +import {useTheme} from 'dash-ui-kit/react' +import {API} from '@renderer/api' +import {IdentityImportResult} from '@renderer/api/types' +import {Button, CrossIcon, Input, PlusIcon, SuccessIcon, Text} from '../dash-ui-kit-enxtended' + +interface ImportIdentityModalProps { + isOpen: boolean + onClose: () => void + walletId: string | null + onImported: () => void +} + +interface KeyInput { + id: number + value: string +} + +export default function ImportIdentity({ + isOpen, + onClose, + walletId, + onImported, +}: ImportIdentityModalProps): React.JSX.Element | null { + const {theme} = useTheme() + const [identifier, setIdentifier] = useState('') + const [keys, setKeys] = useState([{id: 0, value: ''}]) + const [password, setPassword] = useState('') + const [nextKeyId, setNextKeyId] = useState(1) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + + useEffect(() => { + if (!isOpen) return + setIdentifier('') + setKeys([{id: 0, value: ''}]) + setPassword('') + setNextKeyId(1) + setError(null) + setLoading(false) + setResult(null) + }, [isOpen]) + + if (!isOpen) return null + + const updateKey = (id: number, value: string): void => { + setError(null) + setKeys(current => current.map(key => key.id === id ? {...key, value} : key)) + } + + const addKey = (): void => { + setKeys(current => [...current, {id: nextKeyId, value: ''}]) + setNextKeyId(current => current + 1) + } + + const removeKey = (id: number): void => { + setKeys(current => current.filter(key => key.id !== id)) + } + + const privateKeys = keys.map(key => key.value.trim()).filter(Boolean) + const canSubmit = walletId != null && identifier.trim().length > 0 && privateKeys.length > 0 && password.length > 0 && !loading + + const handleImport = async (): Promise => { + if (!walletId || !canSubmit) return + + setLoading(true) + setError(null) + try { + const imported = await API.importIdentity(walletId, identifier, privateKeys, password) + setResult(imported) + setPassword('') + setKeys(current => current.map(key => ({...key, value: ''}))) + onImported() + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not import this identity') + } finally { + setLoading(false) + } + } + + return createPortal( +
+
+
+ + Import Platform identity + + +
+ + {result ? ( +
+
+ + Identity imported + + {result.identifier} + + + Imported key IDs: {result.importedKeyIds.join(', ')} + + {!result.hasTransferKey && ( + + No transfer key was imported, so this wallet cannot move the identity balance. + + )} +
+ +
+ ) : ( +
+ + Add an existing identity to this wallet using one or more private keys. Keys are checked against Platform before they are encrypted and saved. + + +
+ { + setError(null) + setIdentifier(e.target.value) + }} + className={"h-14.25 rounded-[1.25rem] bg-transparent! font-mono"} + colorScheme={error ? 'error' : 'primary'} + disabled={loading} + autoComplete={"off"} + spellCheck={false} + autoFocus + /> + + {keys.map((key, index) => ( +
+ updateKey(key.id, e.target.value)} + className={"h-14.25 rounded-[1.25rem] bg-transparent! font-mono"} + colorScheme={error ? 'error' : 'primary'} + disabled={loading} + autoComplete={"new-password"} + spellCheck={false} + /> + {keys.length > 1 && ( + + )} +
+ ))} + + + + { + setError(null) + setPassword(e.target.value) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleImport() + }} + className={"h-14.25 rounded-[1.25rem] bg-transparent!"} + colorScheme={error ? 'error' : 'primary'} + disabled={loading} + autoComplete={"current-password"} + /> +
+ +
+ + Important: your wallet recovery phrase does not restore imported identity keys. Keep your original private-key backup safe and never share it. + +
+ + {error && ( + + {error} + + )} + +
+ + +
+
+ )} +
+
, + document.body, + ) +} diff --git a/src/renderer/src/components/pages/identities/IdentityCard.tsx b/src/renderer/src/components/pages/identities/IdentityCard.tsx index 2587b226..0459045b 100644 --- a/src/renderer/src/components/pages/identities/IdentityCard.tsx +++ b/src/renderer/src/components/pages/identities/IdentityCard.tsx @@ -22,6 +22,11 @@ export default function IdentityCard({identity}: {identity: Identity}): React.JS {identity.walletAddress} + {identity.isImported && ( + + Imported + + )} {identity.name && Username: {identity.name}} {identity.assetLockTxid && ( diff --git a/src/renderer/src/components/pages/identities/Page.tsx b/src/renderer/src/components/pages/identities/Page.tsx index d418a867..33af7f10 100644 --- a/src/renderer/src/components/pages/identities/Page.tsx +++ b/src/renderer/src/components/pages/identities/Page.tsx @@ -1,9 +1,13 @@ import { Tabs } from "dash-ui-kit/react"; +import {useState} from "react"; import { useIdentities } from "@renderer/hooks/useIdentities"; import { useAuth } from "@renderer/contexts/AuthContext"; import IdentityCard from "./IdentityCard"; import NoResults from "@renderer/components/ui/NoResults"; import ListSkeleton from "@renderer/components/ui/Skeleton"; +import {Button, PlusIcon} from "@renderer/components/dash-ui-kit-enxtended"; +import ImportIdentity from "@renderer/components/modal/ImportIdentity"; +import {invalidateAsyncCache} from "@renderer/hooks/useAsyncWithCache"; export interface Identity { walletAddress: string @@ -13,10 +17,12 @@ export interface Identity { currency: string } assetLockTxid: string | null + isImported: boolean } export default function Identities(): React.JSX.Element { const { status } = useAuth() + const [isImportOpen, setIsImportOpen] = useState(false) const { identities, loading, err } = useIdentities(status?.selectedWalletId ?? undefined) const mappedIdentities: Identity[] = identities.map((item) => ({ @@ -27,6 +33,7 @@ export default function Identities(): React.JSX.Element { currency: 'Credits', }, assetLockTxid: item.assetLockTxid ?? null, + isImported: item.isImported, })) const assetsList = [ @@ -35,6 +42,21 @@ export default function Identities(): React.JSX.Element { label: 'Your Identities', content: (
+
+ +
{loading && } {!loading && err && ( @@ -68,6 +90,16 @@ export default function Identities(): React.JSX.Element { } />
+ setIsImportOpen(false)} + walletId={status?.selectedWalletId ?? null} + onImported={() => { + if (status?.selectedWalletId) { + invalidateAsyncCache('identities', status.selectedWalletId) + } + }} + />
) } diff --git a/src/renderer/src/hooks/useIdentities.tsx b/src/renderer/src/hooks/useIdentities.tsx index ae0316e6..8466f85e 100644 --- a/src/renderer/src/hooks/useIdentities.tsx +++ b/src/renderer/src/hooks/useIdentities.tsx @@ -11,6 +11,7 @@ export type IdentityApiDto = { } derivationPath: string assetLockTxid: string | null + isImported: boolean } const fetchIdentities = (walletId: string): Promise => diff --git a/tests/unit/identityImport.test.ts b/tests/unit/identityImport.test.ts new file mode 100644 index 00000000..95df496d --- /dev/null +++ b/tests/unit/identityImport.test.ts @@ -0,0 +1,189 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {PrivateKeyWASM} from 'dash-platform-sdk/types.js' +import {PlatformAddressService} from '../../src/main/src/services/PlatformAddressService' +import type {WalletDAO} from '../../src/main/src/database/WalletDAO' +import type {IdentityDAO} from '../../src/main/src/database/IdentityDAO' +import type {IdentityKeyDAO} from '../../src/main/src/database/IdentityKeyDAO' +import type {SdkProvider} from '../../src/main/src/providers/SdkProvider' +import type {ShieldedService} from '../../src/main/src/services/ShieldedService' +import {decryptSecret, encryptMnemonic, encryptSecret} from '../../src/main/src/utils' +import type {Wallet} from '../../src/main/src/types/Wallet' + +const WALLET_ID = 'wallet-1' +const IDENTITY_ID = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF' +const PASSWORD = 'password123' +const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const TRANSFER_KEY_HEX = 'a1286dd195e2b8e1f6bdc946c56a53e0c544750d6452ddc0f4c593ef311f21af' + +describe('PlatformAddressService.importIdentity', () => { + let service: PlatformAddressService + let identityDAO: IdentityDAO + let identityKeyDAO: IdentityKeyDAO + let insertImportedIdentity: ReturnType + let publicKeyHash: string + + const wallet: Wallet = { + walletId: WALLET_ID, + network: 'testnet', + label: null, + encryptedMnemonic: encryptMnemonic(MNEMONIC, PASSWORD, 1_000), + selected: true, + platformXpub: 'platform-xpub', + } + + beforeEach(() => { + publicKeyHash = PrivateKeyWASM.fromHex(TRANSFER_KEY_HEX, 'testnet').getPublicKeyHash() + + const walletDAO = { + getWalletById: vi.fn().mockResolvedValue(wallet), + } as unknown as WalletDAO + + identityDAO = { + getByIdentifier: vi.fn().mockResolvedValue(null), + getIdentitiesByWalletId: vi.fn().mockResolvedValue([]), + } as unknown as IdentityDAO + + insertImportedIdentity = vi.fn().mockResolvedValue(undefined) + identityKeyDAO = { + insertImportedIdentity, + } as unknown as IdentityKeyDAO + + const sdkProvider = { + getPlatformSDK: vi.fn().mockReturnValue({ + identities: { + getIdentityByIdentifier: vi.fn().mockResolvedValue({id: {base58: () => IDENTITY_ID}}), + getIdentityPublicKeys: vi.fn().mockResolvedValue([{ + keyId: 3, + purpose: 'TRANSFER', + getPublicKeyHash: () => publicKeyHash, + }]), + }, + }), + } as unknown as SdkProvider + + service = new PlatformAddressService( + walletDAO, + identityDAO, + identityKeyDAO, + sdkProvider, + {} as ShieldedService, + 1_000, + ) + }) + + it('verifies, encrypts and stores a matching transfer key', async () => { + const result = await service.importIdentity(WALLET_ID, IDENTITY_ID, [TRANSFER_KEY_HEX], PASSWORD) + + expect(result).toEqual({ + identifier: IDENTITY_ID, + importedKeyIds: [3], + hasTransferKey: true, + }) + expect(insertImportedIdentity).toHaveBeenCalledOnce() + + const [identity, keys] = insertImportedIdentity.mock.calls[0] + expect(identity).toMatchObject({ + walletId: WALLET_ID, + identifier: IDENTITY_ID, + identityIndex: -1, + isImported: true, + }) + expect(keys).toHaveLength(1) + expect(keys[0].encryptedPrivateKey).not.toContain(TRANSFER_KEY_HEX) + expect(decryptSecret(keys[0].encryptedPrivateKey, MNEMONIC)).toBe(TRANSFER_KEY_HEX) + }) + + it('rejects a key that is not registered on the identity', async () => { + const otherKey = '44a8195e242364b935e9d7ff2106ed109e9baf3800907f5e58a259fdfd1ca5e5' + + await expect(service.importIdentity(WALLET_ID, IDENTITY_ID, [otherKey], PASSWORD)) + .rejects.toThrow('do not belong to this identity') + expect(insertImportedIdentity).not.toHaveBeenCalled() + }) + + it('rejects an incorrect wallet password before storing keys', async () => { + await expect(service.importIdentity(WALLET_ID, IDENTITY_ID, [TRANSFER_KEY_HEX], 'wrong-password')) + .rejects.toThrow('Invalid wallet password') + expect(insertImportedIdentity).not.toHaveBeenCalled() + }) + + it('rejects importing an identity already attached to the wallet', async () => { + vi.mocked(identityDAO.getByIdentifier).mockResolvedValue({ + walletId: WALLET_ID, + identityIndex: 0, + derivationPath: '', + identifier: IDENTITY_ID, + }) + + await expect(service.importIdentity(WALLET_ID, IDENTITY_ID, [TRANSFER_KEY_HEX], PASSWORD)) + .rejects.toThrow('already in this wallet') + expect(insertImportedIdentity).not.toHaveBeenCalled() + }) + + it('uses an imported transfer key to sign identity credit transfers', async () => { + const privateKey = PrivateKeyWASM.fromHex(TRANSFER_KEY_HEX, 'testnet') + const identityPublicKey = { + keyId: 3, + purpose: 'TRANSFER', + getPublicKeyHash: () => privateKey.getPublicKeyHash(), + } + const sign = vi.fn().mockReturnValue(new Uint8Array([1, 2, 3])) + const stateTransition = { + signature: null, + signaturePublicKeyId: null, + sign, + hash: vi.fn().mockReturnValue('state-transition-hash'), + } + const broadcast = vi.fn().mockResolvedValue(undefined) + const waitForStateTransitionResult = vi.fn().mockResolvedValue(undefined) + + const signingService = new PlatformAddressService( + {getWalletById: vi.fn().mockResolvedValue(wallet)} as unknown as WalletDAO, + {getIdentitiesByWalletId: vi.fn().mockResolvedValue([{ + walletId: WALLET_ID, + identityIndex: -1, + derivationPath: '', + identifier: IDENTITY_ID, + isImported: true, + }])} as unknown as IdentityDAO, + {getByIdentity: vi.fn().mockResolvedValue([{ + walletId: WALLET_ID, + identityIdentifier: IDENTITY_ID, + keyId: 3, + publicKeyHash: privateKey.getPublicKeyHash(), + encryptedPrivateKey: encryptSecret(TRANSFER_KEY_HEX, MNEMONIC, 1_000), + }])} as unknown as IdentityKeyDAO, + { + getPlatformSDK: vi.fn().mockReturnValue({ + keyPair: { + mnemonicToSeed: vi.fn().mockReturnValue(new Uint8Array([1])), + seedToHdKey: vi.fn().mockReturnValue({}), + }, + identities: { + getIdentityPublicKeys: vi.fn().mockResolvedValue([identityPublicKey]), + getIdentityBalance: vi.fn().mockResolvedValue(20_000_000n), + getIdentityNonce: vi.fn().mockResolvedValue(1n), + createStateTransition: vi.fn().mockReturnValue(stateTransition), + }, + stateTransitions: {broadcast, waitForStateTransitionResult}, + }), + } as unknown as SdkProvider, + {} as ShieldedService, + 1_000, + ) + + await signingService.transferIdentityCredits( + WALLET_ID, + IDENTITY_ID, + '7XvBHxC16cvcLwCf8M2oeG8rKpHMbCqgRrS2mKsGJjVG', + 1_000_000n, + PASSWORD, + ) + + expect(sign).toHaveBeenCalledOnce() + expect(sign.mock.calls[0][0].hex().toLowerCase()).toBe(TRANSFER_KEY_HEX) + expect(sign.mock.calls[0][1]).toBe(identityPublicKey) + expect(stateTransition.signaturePublicKeyId).toBe(3) + expect(broadcast).toHaveBeenCalledWith(stateTransition) + }) +}) diff --git a/tests/unit/identityKeyStorage.test.ts b/tests/unit/identityKeyStorage.test.ts new file mode 100644 index 00000000..1920feff --- /dev/null +++ b/tests/unit/identityKeyStorage.test.ts @@ -0,0 +1,75 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest' +import type {Knex} from 'knex' +import {getKnex, migrateKnex} from '../../src/main/src/utils' +import {IdentityDAO} from '../../src/main/src/database/IdentityDAO' +import {IdentityKeyDAO} from '../../src/main/src/database/IdentityKeyDAO' +import {WalletDAO} from '../../src/main/src/database/WalletDAO' + +const WALLET_ID = 'wallet-1' +const IDENTITY_ID = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF' + +describe('imported identity key storage', () => { + let knex: Knex + + beforeEach(async () => { + knex = getKnex() + await migrateKnex(knex) + await knex('wallet').insert({ + wallet_id: WALLET_ID, + network: 'testnet', + encrypted_mnemonic: 'encrypted', + selected: true, + }) + }) + + afterEach(async () => { + await knex.destroy() + }) + + it('stores the identity and its encrypted keys atomically', async () => { + const keyDAO = new IdentityKeyDAO(knex) + await keyDAO.insertImportedIdentity({ + walletId: WALLET_ID, + identityIndex: -1, + derivationPath: '', + identifier: IDENTITY_ID, + isImported: true, + }, [{ + walletId: WALLET_ID, + identityIdentifier: IDENTITY_ID, + keyId: 3, + publicKeyHash: 'abcd', + encryptedPrivateKey: 'encrypted-key', + }]) + + const identities = await new IdentityDAO(knex).getIdentitiesByWalletId(WALLET_ID) + expect(identities).toEqual([expect.objectContaining({ + identifier: IDENTITY_ID, + isImported: true, + })]) + expect(await keyDAO.getByIdentity(WALLET_ID, IDENTITY_ID)).toEqual([expect.objectContaining({ + keyId: 3, + encryptedPrivateKey: 'encrypted-key', + })]) + }) + + it('removes imported keys when their wallet is deleted', async () => { + const keyDAO = new IdentityKeyDAO(knex) + await keyDAO.insertImportedIdentity({ + walletId: WALLET_ID, + identityIndex: -1, + derivationPath: '', + identifier: IDENTITY_ID, + isImported: true, + }, [{ + walletId: WALLET_ID, + identityIdentifier: IDENTITY_ID, + keyId: 3, + publicKeyHash: 'abcd', + encryptedPrivateKey: 'encrypted-key', + }]) + + expect((await new WalletDAO(knex).deleteWallet(WALLET_ID)).success).toBe(true) + expect(await knex('identity_keys').count<{count: number}>({count: '*'}).first()).toEqual({count: 0}) + }) +}) diff --git a/tests/unit/identityKeys.test.ts b/tests/unit/identityKeys.test.ts index 7031c8bc..485f76aa 100644 --- a/tests/unit/identityKeys.test.ts +++ b/tests/unit/identityKeys.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { matchIdentityKey, IdentityKeyDescriptor, DerivedKeyHash } from '../../src/main/src/utils/identityKeys' +import { matchIdentityKey, IdentityKeyDescriptor, DerivedKeyHash, parseIdentityPrivateKey } from '../../src/main/src/utils/identityKeys' function key(keyId: number, purpose: string, publicKeyHashHex: string): IdentityKeyDescriptor { return { keyId, purpose, publicKeyHashHex } @@ -39,3 +39,19 @@ describe('matchIdentityKey', () => { expect(result).toEqual({ keyId: 3, keyIndex: 0 }) }) }) + +describe('parseIdentityPrivateKey', () => { + it('accepts 64-character hex', () => { + const hex = 'a1286dd195e2b8e1f6bdc946c56a53e0c544750d6452ddc0f4c593ef311f21af' + expect(parseIdentityPrivateKey(hex, 'testnet').hex().toLowerCase()).toBe(hex) + }) + + it('accepts WIF', () => { + const wif = 'cPGCETHtoevguQoyTSdsowCEF91yqhrcikcvBNK2CuTwpSLV7m9Z' + expect(parseIdentityPrivateKey(wif, 'testnet').hex()).toHaveLength(64) + }) + + it('rejects malformed values', () => { + expect(() => parseIdentityPrivateKey('not-a-private-key', 'testnet')).toThrow(/64-character hex or WIF/) + }) +}) From 7fddae82d14b49d674706843eeeb1b96e1ed6302 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 24 Jul 2026 15:34:34 +0000 Subject: [PATCH 2/4] feat: resolve identity names and improve send sources --- .../src/services/PlatformAddressService.ts | 60 ++++++++++++++++--- .../src/components/modal/ImportIdentity.tsx | 4 +- .../pages/identities/IdentityCard.tsx | 11 ++++ .../pages/transfer/EndpointPicker.tsx | 2 +- .../components/pages/transfer/TransferHub.tsx | 32 +++++++++- src/renderer/src/utils/transferMatrix.ts | 2 +- tests/unit/identityImport.test.ts | 50 +++++++++++++++- 7 files changed, 144 insertions(+), 17 deletions(-) diff --git a/src/main/src/services/PlatformAddressService.ts b/src/main/src/services/PlatformAddressService.ts index ec1a3f9c..0bf1fdbe 100644 --- a/src/main/src/services/PlatformAddressService.ts +++ b/src/main/src/services/PlatformAddressService.ts @@ -48,6 +48,7 @@ const PLATFORM_ADDRESS_LOOKAHEAD = 20 const IDENTITY_KEY_LOOKAHEAD = 20 const MAX_DISCOVERY_BATCHES = 50 const COIN_TYPE: Record = {mainnet: 5, testnet: 1} +const IDENTITY_IDENTIFIER_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{42,44}$/ // Platform (L2) addresses follow DIP-17: m/9'/coinType'/17'/account'/0'/index. // The account-level xpub is persisted per wallet so the address list derives @@ -125,13 +126,9 @@ export class PlatformAddressService { throw new Error('Invalid wallet password') } - const identifier = identityIdentifier.trim() - if (identifier.length === 0) { - throw new Error('Identity identifier is required') - } - - if (await this.identityDAO.getByIdentifier(walletId, identifier) != null) { - throw new Error('Identity is already in this wallet') + const identityReference = identityIdentifier.trim() + if (identityReference.length === 0) { + throw new Error('Identity identifier or DPNS name is required') } const values = privateKeyValues.map(value => value.trim()).filter(Boolean) @@ -139,12 +136,17 @@ export class PlatformAddressService { throw new Error('At least one private key is required') } + const identifier = await this.resolveIdentityIdentifier(identityReference, wallet.network) + + if (await this.identityDAO.getByIdentifier(walletId, identifier) != null) { + throw new Error('Identity is already in this wallet') + } + let identityPublicKeys: IdentityPublicKeyWASM[] try { - await this.platformSDK(wallet.network).identities.getIdentityByIdentifier(identifier) identityPublicKeys = await this.platformSDK(wallet.network).identities.getIdentityPublicKeys(identifier) } catch { - throw new Error('Identity was not found on the selected network') + throw new Error(`Identity keys could not be loaded from ${wallet.network}`) } const matched = values.map(value => { @@ -200,6 +202,46 @@ export class PlatformAddressService { } } + private async resolveIdentityIdentifier(reference: string, network: Network): Promise { + const sdk = this.platformSDK(network) + + if (IDENTITY_IDENTIFIER_PATTERN.test(reference)) { + try { + const identity = await sdk.identities.getIdentityByIdentifier(reference) + return identity.id.base58() + } catch { + throw new Error(`Identity was not found on ${network}`) + } + } + + const lowered = reference.toLowerCase() + const fullName = lowered.endsWith('.dash') ? lowered : `${lowered}.dash` + const parts = fullName.split('.') + if (parts.length !== 2 || parts[0].length === 0 || parts[1] !== 'dash') { + throw new Error('Enter an identity identifier, DPNS name, or bare .dash username') + } + + try { + const normalizedLabel = sdk.names.normalizeLabel(parts[0]) + const documents = await sdk.names.searchByName(fullName) + const exact = documents.find(document => { + const properties = document.properties as Record + return properties.normalizedLabel === normalizedLabel + && properties.normalizedParentDomainName === 'dash' + }) + + if (exact == null) { + throw new Error('not found') + } + + const identifier = exact.ownerId.base58() + const identity = await sdk.identities.getIdentityByIdentifier(identifier) + return identity.id.base58() + } catch { + throw new Error(`DPNS name ${fullName} was not found on ${network}`) + } + } + async sendPlatformTransfer( walletId: string, fromPlatformAddress: string, diff --git a/src/renderer/src/components/modal/ImportIdentity.tsx b/src/renderer/src/components/modal/ImportIdentity.tsx index 79cf5152..0d8ac6a5 100644 --- a/src/renderer/src/components/modal/ImportIdentity.tsx +++ b/src/renderer/src/components/modal/ImportIdentity.tsx @@ -128,14 +128,14 @@ export default function ImportIdentity({ ) : (
- Add an existing identity to this wallet using one or more private keys. Keys are checked against Platform before they are encrypted and saved. + Enter an identity ID, a DPNS name such as alice.dash, or just alice. Keys are checked against Platform before they are encrypted and saved.
{ diff --git a/src/renderer/src/components/pages/identities/IdentityCard.tsx b/src/renderer/src/components/pages/identities/IdentityCard.tsx index 0459045b..ec576f8c 100644 --- a/src/renderer/src/components/pages/identities/IdentityCard.tsx +++ b/src/renderer/src/components/pages/identities/IdentityCard.tsx @@ -6,10 +6,12 @@ import CopyButton from "@renderer/components/ui/CopyButton"; import CreditsAmount from "@renderer/components/ui/CreditsAmount"; import { useAuth } from "@renderer/contexts/AuthContext"; import { transactionUrl, openExternal } from "@renderer/utils/explorer"; +import { useNavigate } from "react-router-dom"; export default function IdentityCard({identity}: {identity: Identity}): React.JSX.Element { const { status } = useAuth() const network = status?.network ?? null + const navigate = useNavigate() return (
@@ -50,6 +52,15 @@ export default function IdentityCard({identity}: {identity: Identity}): React.JS } currency={""} /> + {identity.balance.total > 0n && ( + + )}
) } diff --git a/src/renderer/src/components/pages/transfer/EndpointPicker.tsx b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx index 87874ec6..51ce6e5c 100644 --- a/src/renderer/src/components/pages/transfer/EndpointPicker.tsx +++ b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx @@ -97,7 +97,7 @@ function IdentitySelect({identities, selected, onSelect}: IdentitySelectProps):
) : ( - No identities in this wallet + No funded identities in this wallet )} diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 125813aa..765a0b71 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -86,7 +86,7 @@ export default function TransferHub(): React.JSX.Element { const [fromKind, setFromKind] = useState(() => initialSourceKind(searchParams.get('from'))) const [toKind, setToKind] = useState(() => initialDestinationKind(searchParams.get('to'))) const [fromAddress, setFromAddress] = useState('') - const [fromIdentity, setFromIdentity] = useState('') + const [fromIdentity, setFromIdentity] = useState(() => searchParams.get('source') ?? '') const [toValue, setToValue] = useState('') const [amount, setAmount] = useState('') const [acked, setAcked] = useState(false) @@ -154,7 +154,17 @@ export default function TransferHub(): React.JSX.Element { ) const selectedSource = fundedAddresses.find(a => a.platformAddress === fromAddress) ?? defaultSource - const selectedIdentity = identities.find(i => i.identifier === fromIdentity) ?? identities[0] + const fundedIdentities = useMemo( + () => identities + .filter(identity => BigInt(String(identity.balance.amount)) > 0n) + .sort((a, b) => { + const aBalance = BigInt(String(a.balance.amount)) + const bBalance = BigInt(String(b.balance.amount)) + return aBalance < bBalance ? 1 : aBalance > bBalance ? -1 : 0 + }), + [identities], + ) + const selectedIdentity = fundedIdentities.find(i => i.identifier === fromIdentity) ?? fundedIdentities[0] const coreAddresses = useMemo( () => [...receiving, ...change] @@ -356,11 +366,27 @@ export default function TransferHub(): React.JSX.Element { platformAddresses={fundedAddresses} selectedPlatformAddress={selectedSource} onPlatformAddressChange={setFromAddress} - identities={identities} + identities={fundedIdentities} selectedIdentity={selectedIdentity} onIdentityChange={setFromIdentity} /> + {fromKind === SourceKind.PlatformAddress && fundedAddresses.length === 0 && fundedIdentities.length > 0 && ( + + )} + {(operation === TransferOperation.CoreSend || shieldedSpendOperation) && (
= [ {kind: SourceKind.Core, label: 'Dash Core (L1)'}, {kind: SourceKind.PlatformAddress, label: 'Platform address'}, - {kind: SourceKind.Identity, label: 'Identity'}, + {kind: SourceKind.Identity, label: 'Identity balance'}, {kind: SourceKind.Shielded, label: 'Shielded balance'}, ] diff --git a/tests/unit/identityImport.test.ts b/tests/unit/identityImport.test.ts index 95df496d..ffdcb18e 100644 --- a/tests/unit/identityImport.test.ts +++ b/tests/unit/identityImport.test.ts @@ -20,6 +20,8 @@ describe('PlatformAddressService.importIdentity', () => { let identityDAO: IdentityDAO let identityKeyDAO: IdentityKeyDAO let insertImportedIdentity: ReturnType + let getIdentityByIdentifier: ReturnType + let searchByName: ReturnType let publicKeyHash: string const wallet: Wallet = { @@ -48,16 +50,22 @@ describe('PlatformAddressService.importIdentity', () => { insertImportedIdentity, } as unknown as IdentityKeyDAO + getIdentityByIdentifier = vi.fn().mockResolvedValue({id: {base58: () => IDENTITY_ID}}) + searchByName = vi.fn().mockResolvedValue([]) const sdkProvider = { getPlatformSDK: vi.fn().mockReturnValue({ identities: { - getIdentityByIdentifier: vi.fn().mockResolvedValue({id: {base58: () => IDENTITY_ID}}), + getIdentityByIdentifier, getIdentityPublicKeys: vi.fn().mockResolvedValue([{ keyId: 3, purpose: 'TRANSFER', getPublicKeyHash: () => publicKeyHash, }]), }, + names: { + normalizeLabel: vi.fn((label: string) => label), + searchByName, + }, }), } as unknown as SdkProvider @@ -93,6 +101,46 @@ describe('PlatformAddressService.importIdentity', () => { expect(decryptSecret(keys[0].encryptedPrivateKey, MNEMONIC)).toBe(TRANSFER_KEY_HEX) }) + it.each(['latte', 'latte.dash'])('resolves the DPNS reference %s before importing', async (reference) => { + searchByName.mockResolvedValue([ + { + ownerId: {base58: () => '8h3vUj4TFp7L9XQmW2Kc6nYzR5sAeBdG1iJoNvCxPkEt'}, + properties: { + normalizedLabel: 'latte-other', + normalizedParentDomainName: 'dash', + }, + }, + { + ownerId: {base58: () => IDENTITY_ID}, + properties: { + normalizedLabel: 'latte', + normalizedParentDomainName: 'dash', + }, + }, + ]) + + const result = await service.importIdentity(WALLET_ID, reference, [TRANSFER_KEY_HEX], PASSWORD) + + expect(searchByName).toHaveBeenCalledWith('latte.dash') + expect(getIdentityByIdentifier).toHaveBeenCalledWith(IDENTITY_ID) + expect(result.identifier).toBe(IDENTITY_ID) + expect(insertImportedIdentity).toHaveBeenCalledOnce() + }) + + it('rejects a DPNS prefix match when the exact name is not registered', async () => { + searchByName.mockResolvedValue([{ + ownerId: {base58: () => IDENTITY_ID}, + properties: { + normalizedLabel: 'latte-other', + normalizedParentDomainName: 'dash', + }, + }]) + + await expect(service.importIdentity(WALLET_ID, 'latte', [TRANSFER_KEY_HEX], PASSWORD)) + .rejects.toThrow('DPNS name latte.dash was not found on testnet') + expect(insertImportedIdentity).not.toHaveBeenCalled() + }) + it('rejects a key that is not registered on the identity', async () => { const otherKey = '44a8195e242364b935e9d7ff2106ed109e9baf3800907f5e58a259fdfd1ca5e5' From 3702e78bbff8b76348f6616633cf65dc53a1a434 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Fri, 24 Jul 2026 15:34:34 +0000 Subject: [PATCH 3/4] test: update SDK provider fixtures --- tests/api/createWallet.test.ts | 9 +++++++-- tests/api/getAddresses.test.ts | 8 ++++++-- tests/unit/coreTransaction.test.ts | 2 +- tests/unit/identityRegistration.test.ts | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/api/createWallet.test.ts b/tests/api/createWallet.test.ts index 7dd709ee..4ce40b31 100644 --- a/tests/api/createWallet.test.ts +++ b/tests/api/createWallet.test.ts @@ -1,8 +1,9 @@ import {describe, it, expect, beforeEach, vi} from 'vitest' -import {SdkProvider} from '../../src/main/src/services/SdkProvider' +import {SdkProvider} from '../../src/main/src/providers/SdkProvider' import {CreateWalletHandler} from '../../src/main/src/api/wallet/createWallet' import {WalletService} from '../../src/main/src/services/WalletService' import {WalletSyncService} from '../../src/main/src/services/WalletSyncService' +import type {ShieldedService} from '../../src/main/src/services/ShieldedService' import {ApplicationService} from '../../src/main/src/services/ApplicationService' import {WalletDAO} from '../../src/main/src/database/WalletDAO' import {AddressDAO} from '../../src/main/src/database/AddressDAO' @@ -38,12 +39,16 @@ describe('CreateWalletHandler', () => { vi.spyOn(sdk.identities, 'getIdentityByNonUniquePublicKeyHash').mockRejectedValue(new Error('offline test')) const preferences = Preferences.default() + preferences.general.connectionType = 'p2p' const applicationService = new ApplicationService(preferences) const walletSyncService = new WalletSyncService(walletDAO, addressDAO, transactionDAO) + const shieldedService = { + initAddresses: vi.fn().mockResolvedValue(undefined), + } as unknown as ShieldedService const walletService = new WalletService( walletDAO, addressDAO, identityDAO, transactionDAO, - applicationService, walletSyncService, sdkProvider, TEST_PBKDF2_ITERATIONS, + applicationService, walletSyncService, sdkProvider, TEST_PBKDF2_ITERATIONS, shieldedService, ) handler = new CreateWalletHandler(walletService, addressDAO, walletSyncService) diff --git a/tests/api/getAddresses.test.ts b/tests/api/getAddresses.test.ts index fa3a69ae..0ae8deef 100644 --- a/tests/api/getAddresses.test.ts +++ b/tests/api/getAddresses.test.ts @@ -1,9 +1,10 @@ import {describe, it, expect, beforeEach, vi} from 'vitest' -import {SdkProvider} from '../../src/main/src/services/SdkProvider' +import {SdkProvider} from '../../src/main/src/providers/SdkProvider' import {GetWalletAddressesHandler} from '../../src/main/src/api/wallet/getAddresses' import {CreateWalletHandler} from '../../src/main/src/api/wallet/createWallet' import {WalletService} from '../../src/main/src/services/WalletService' import {WalletSyncService} from '../../src/main/src/services/WalletSyncService' +import type {ShieldedService} from '../../src/main/src/services/ShieldedService' import {ApplicationService} from '../../src/main/src/services/ApplicationService' import {WalletDAO} from '../../src/main/src/database/WalletDAO' import {AddressDAO} from '../../src/main/src/database/AddressDAO' @@ -40,10 +41,13 @@ describe('GetWalletAddressesHandler', () => { preferences.general.connectionType = 'p2p' const applicationService = new ApplicationService(preferences) const walletSyncService = new WalletSyncService(walletDAO, addressDAO, transactionDAO) + const shieldedService = { + initAddresses: vi.fn().mockResolvedValue(undefined), + } as unknown as ShieldedService const walletService = new WalletService( walletDAO, addressDAO, identityDAO, transactionDAO, - applicationService, walletSyncService, sdkProvider, TEST_PBKDF2_ITERATIONS, + applicationService, walletSyncService, sdkProvider, TEST_PBKDF2_ITERATIONS, shieldedService, ) createWalletHandler = new CreateWalletHandler(walletService, addressDAO, walletSyncService) diff --git a/tests/unit/coreTransaction.test.ts b/tests/unit/coreTransaction.test.ts index 9be790c3..db6442d1 100644 --- a/tests/unit/coreTransaction.test.ts +++ b/tests/unit/coreTransaction.test.ts @@ -1,7 +1,7 @@ import {describe, it, expect} from 'vitest' import {Output, Script, TransactionType, utils as sdkUtils} from 'dash-core-sdk' import {CoreTransactionService, TransferInput} from '../../src/main/src/services/CoreTransactionService' -import {SdkProvider} from '../../src/main/src/services/SdkProvider' +import {SdkProvider} from '../../src/main/src/providers/SdkProvider' // classifyRecipientAddress only touches the SDK's pure address utils, so the // service can be built with a stub SDK for this suite. diff --git a/tests/unit/identityRegistration.test.ts b/tests/unit/identityRegistration.test.ts index bc964fc6..b2384586 100644 --- a/tests/unit/identityRegistration.test.ts +++ b/tests/unit/identityRegistration.test.ts @@ -1,5 +1,5 @@ import {describe, it, expect, vi} from 'vitest' -import {SdkProvider} from '../../src/main/src/services/SdkProvider' +import {SdkProvider} from '../../src/main/src/providers/SdkProvider' import {IdentityRegistrationService, IDENTITY_KEY_DEFINITIONS} from '../../src/main/src/services/IdentityRegistrationService' const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' From d4d8a8fba8d82eb39a11575819495474bb6fb674 Mon Sep 17 00:00:00 2001 From: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:49:06 +0000 Subject: [PATCH 4/4] fix: harden imported identity discovery --- .../src/services/PlatformAddressService.ts | 65 +++++++++++----- src/main/src/services/WalletService.ts | 45 ++++++----- src/main/src/utils/identityKeys.ts | 7 +- tests/api/createWallet.test.ts | 8 +- tests/unit/identityImport.test.ts | 40 ++++++++++ tests/unit/identityKeys.test.ts | 7 +- tests/unit/identityListing.test.ts | 74 +++++++++++++++++++ 7 files changed, 204 insertions(+), 42 deletions(-) create mode 100644 tests/unit/identityListing.test.ts diff --git a/src/main/src/services/PlatformAddressService.ts b/src/main/src/services/PlatformAddressService.ts index 0bf1fdbe..51b4e0ac 100644 --- a/src/main/src/services/PlatformAddressService.ts +++ b/src/main/src/services/PlatformAddressService.ts @@ -12,6 +12,7 @@ import { IdentityPublicKeyInCreationWASM, PrivateKeyWASM, IdentityPublicKeyWASM, + IdentifierWASM, } from 'dash-platform-sdk/types.js' import type {WalletDAO} from '../database/WalletDAO' import type {ShieldedService} from './ShieldedService' @@ -48,7 +49,10 @@ const PLATFORM_ADDRESS_LOOKAHEAD = 20 const IDENTITY_KEY_LOOKAHEAD = 20 const MAX_DISCOVERY_BATCHES = 50 const COIN_TYPE: Record = {mainnet: 5, testnet: 1} -const IDENTITY_IDENTIFIER_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{42,44}$/ + +function isNotFoundError(error: unknown): boolean { + return error instanceof Error && /\bnot found\b/i.test(error.message) +} // Platform (L2) addresses follow DIP-17: m/9'/coinType'/17'/account'/0'/index. // The account-level xpub is persisted per wallet so the address list derives @@ -204,13 +208,28 @@ export class PlatformAddressService { private async resolveIdentityIdentifier(reference: string, network: Network): Promise { const sdk = this.platformSDK(network) + const isExplicitName = reference.includes('.') + let isIdentifier = false - if (IDENTITY_IDENTIFIER_PATTERN.test(reference)) { + if (!isExplicitName) { try { - const identity = await sdk.identities.getIdentityByIdentifier(reference) - return identity.id.base58() + new IdentifierWASM(reference) + isIdentifier = true } catch { - throw new Error(`Identity was not found on ${network}`) + // A bare DPNS label is expected not to parse as an identifier. + } + + if (isIdentifier) { + try { + const identity = await sdk.identities.getIdentityByIdentifier(reference) + return identity.id.base58() + } catch (error) { + if (!isNotFoundError(error)) { + throw new Error(`Identity lookup failed on ${network}`) + } + // A Base58-looking bare DPNS label can also be a syntactically valid + // identifier. Fall through to exact-name resolution before failing. + } } } @@ -221,24 +240,36 @@ export class PlatformAddressService { throw new Error('Enter an identity identifier, DPNS name, or bare .dash username') } + const normalizedLabel = sdk.names.normalizeLabel(parts[0]) + let documents: Awaited> try { - const normalizedLabel = sdk.names.normalizeLabel(parts[0]) - const documents = await sdk.names.searchByName(fullName) - const exact = documents.find(document => { - const properties = document.properties as Record - return properties.normalizedLabel === normalizedLabel - && properties.normalizedParentDomainName === 'dash' - }) + documents = await sdk.names.searchByName(fullName) + } catch { + throw new Error(`DPNS lookup failed on ${network}`) + } - if (exact == null) { - throw new Error('not found') + const exact = documents.find(document => { + const properties = document.properties as Record + return properties.normalizedLabel === normalizedLabel + && properties.normalizedParentDomainName === 'dash' + }) + + if (exact == null) { + if (isIdentifier) { + throw new Error(`No identity or DPNS name matching ${reference} was found on ${network}`) } + throw new Error(`DPNS name ${fullName} was not found on ${network}`) + } - const identifier = exact.ownerId.base58() + const identifier = exact.ownerId.base58() + try { const identity = await sdk.identities.getIdentityByIdentifier(identifier) return identity.id.base58() - } catch { - throw new Error(`DPNS name ${fullName} was not found on ${network}`) + } catch (error) { + if (isNotFoundError(error)) { + throw new Error(`Identity for DPNS name ${fullName} was not found on ${network}`) + } + throw new Error(`Identity lookup failed on ${network}`) } } diff --git a/src/main/src/services/WalletService.ts b/src/main/src/services/WalletService.ts index 66104332..cf80a255 100644 --- a/src/main/src/services/WalletService.ts +++ b/src/main/src/services/WalletService.ts @@ -1,4 +1,5 @@ import {randomBytes} from 'crypto' +import type {DashPlatformSDK} from 'dash-platform-sdk' import {SdkProvider} from '../providers/SdkProvider' import {WalletDAO} from '../database/WalletDAO' import {AddressDAO} from '../database/AddressDAO' @@ -804,33 +805,41 @@ export class WalletService { const results: IdentityInfo[] = [] for (const entry of stored) { + let identity: Awaited> + try { + identity = await sdk.identities.getIdentityByIdentifier(entry.identifier) + } catch { + // Identity is not registered on Platform yet, or could not currently + // be loaded. Do not let one entry prevent the others from rendering. + continue + } + + let alias: string | null = null try { - const identity = await sdk.identities.getIdentityByIdentifier(entry.identifier) const [aliasDocument] = await sdk.names.searchByIdentity(entry.identifier) const {label, parentDomainName} = aliasDocument?.properties ?? {} - let alias: string | null = null - if (label != null && parentDomainName != null) { alias = `${label}.${parentDomainName}` } - - // TODO: Implement read usd amount - results.push({ - identityIndex: entry.identityIndex, - identifier: identity.id.base58(), - alias, - balance: { - amount: BigInt(identity.balance), - usdAmount: '0.0' - }, - derivationPath: entry.derivationPath, - assetLockTxid: entry.assetLockTxid ?? null, - isImported: entry.isImported ?? false, - }) } catch { - // identity not registered on platform yet, skip + // DPNS is optional. A transient name lookup failure must not hide an + // otherwise valid identity or its balance. } + + // TODO: Implement read usd amount + results.push({ + identityIndex: entry.identityIndex, + identifier: identity.id.base58(), + alias, + balance: { + amount: BigInt(identity.balance), + usdAmount: '0.0' + }, + derivationPath: entry.derivationPath, + assetLockTxid: entry.assetLockTxid ?? null, + isImported: entry.isImported ?? false, + }) } return results diff --git a/src/main/src/utils/identityKeys.ts b/src/main/src/utils/identityKeys.ts index e72da3c7..26aa3f26 100644 --- a/src/main/src/utils/identityKeys.ts +++ b/src/main/src/utils/identityKeys.ts @@ -48,14 +48,15 @@ export function matchIdentityKey( export function parseIdentityPrivateKey(value: string, network: Network): PrivateKeyWASM { const trimmed = value.trim() + const hex = trimmed.replace(/^0x/i, '') - if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { - return PrivateKeyWASM.fromHex(trimmed, network) + if (/^[0-9a-fA-F]{64}$/.test(hex)) { + return PrivateKeyWASM.fromHex(hex, network) } if (/^[1-9A-HJ-NP-Za-km-z]{51,52}$/.test(trimmed)) { return PrivateKeyWASM.fromWIF(trimmed) } - throw new Error('Private keys must be 64-character hex or WIF') + throw new Error('Private keys must be 64-character hex (optionally 0x-prefixed) or WIF') } diff --git a/tests/api/createWallet.test.ts b/tests/api/createWallet.test.ts index 4ce40b31..42a5e53d 100644 --- a/tests/api/createWallet.test.ts +++ b/tests/api/createWallet.test.ts @@ -32,11 +32,13 @@ describe('CreateWalletHandler', () => { const transactionDAO = new TransactionDAO(knex) const sdkProvider = new SdkProvider() - const sdk = sdkProvider.getPlatformSDK('testnet') // Short-circuit identity discovery so wallet creation stays offline. // WalletService.createWallet catches these errors and proceeds. - vi.spyOn(sdk.identities, 'getIdentityByPublicKeyHash').mockRejectedValue(new Error('offline test')) - vi.spyOn(sdk.identities, 'getIdentityByNonUniquePublicKeyHash').mockRejectedValue(new Error('offline test')) + for (const network of ['testnet', 'mainnet'] as const) { + const sdk = sdkProvider.getPlatformSDK(network) + vi.spyOn(sdk.identities, 'getIdentityByPublicKeyHash').mockRejectedValue(new Error('offline test')) + vi.spyOn(sdk.identities, 'getIdentityByNonUniquePublicKeyHash').mockRejectedValue(new Error('offline test')) + } const preferences = Preferences.default() preferences.general.connectionType = 'p2p' diff --git a/tests/unit/identityImport.test.ts b/tests/unit/identityImport.test.ts index ffdcb18e..1703c141 100644 --- a/tests/unit/identityImport.test.ts +++ b/tests/unit/identityImport.test.ts @@ -11,6 +11,7 @@ import type {Wallet} from '../../src/main/src/types/Wallet' const WALLET_ID = 'wallet-1' const IDENTITY_ID = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF' +const SHORT_IDENTITY_ID = '11111111111111111111111111111111' const PASSWORD = 'password123' const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' const TRANSFER_KEY_HEX = 'a1286dd195e2b8e1f6bdc946c56a53e0c544750d6452ddc0f4c593ef311f21af' @@ -101,6 +102,16 @@ describe('PlatformAddressService.importIdentity', () => { expect(decryptSecret(keys[0].encryptedPrivateKey, MNEMONIC)).toBe(TRANSFER_KEY_HEX) }) + it('accepts a valid identifier shorter than the usual 42 to 44 characters', async () => { + getIdentityByIdentifier.mockResolvedValueOnce({id: {base58: () => SHORT_IDENTITY_ID}}) + + const result = await service.importIdentity(WALLET_ID, SHORT_IDENTITY_ID, [TRANSFER_KEY_HEX], PASSWORD) + + expect(getIdentityByIdentifier).toHaveBeenCalledWith(SHORT_IDENTITY_ID) + expect(result.identifier).toBe(SHORT_IDENTITY_ID) + expect(insertImportedIdentity).toHaveBeenCalledOnce() + }) + it.each(['latte', 'latte.dash'])('resolves the DPNS reference %s before importing', async (reference) => { searchByName.mockResolvedValue([ { @@ -127,6 +138,35 @@ describe('PlatformAddressService.importIdentity', () => { expect(insertImportedIdentity).toHaveBeenCalledOnce() }) + it('falls back to a bare DPNS name when its label looks like an identifier', async () => { + getIdentityByIdentifier + .mockRejectedValueOnce(new Error(`Identity with identifier ${IDENTITY_ID} not found`)) + .mockResolvedValueOnce({id: {base58: () => IDENTITY_ID}}) + searchByName.mockResolvedValue([{ + ownerId: {base58: () => IDENTITY_ID}, + properties: { + normalizedLabel: IDENTITY_ID.toLowerCase(), + normalizedParentDomainName: 'dash', + }, + }]) + + const result = await service.importIdentity(WALLET_ID, IDENTITY_ID, [TRANSFER_KEY_HEX], PASSWORD) + + expect(searchByName).toHaveBeenCalledWith(`${IDENTITY_ID.toLowerCase()}.dash`) + expect(getIdentityByIdentifier).toHaveBeenCalledTimes(2) + expect(result.identifier).toBe(IDENTITY_ID) + expect(insertImportedIdentity).toHaveBeenCalledOnce() + }) + + it('does not report an identity lookup connectivity failure as not found', async () => { + getIdentityByIdentifier.mockRejectedValueOnce(new Error('DAPI request timed out')) + + await expect(service.importIdentity(WALLET_ID, IDENTITY_ID, [TRANSFER_KEY_HEX], PASSWORD)) + .rejects.toThrow('Identity lookup failed on testnet') + expect(searchByName).not.toHaveBeenCalled() + expect(insertImportedIdentity).not.toHaveBeenCalled() + }) + it('rejects a DPNS prefix match when the exact name is not registered', async () => { searchByName.mockResolvedValue([{ ownerId: {base58: () => IDENTITY_ID}, diff --git a/tests/unit/identityKeys.test.ts b/tests/unit/identityKeys.test.ts index 485f76aa..d8911691 100644 --- a/tests/unit/identityKeys.test.ts +++ b/tests/unit/identityKeys.test.ts @@ -46,12 +46,17 @@ describe('parseIdentityPrivateKey', () => { expect(parseIdentityPrivateKey(hex, 'testnet').hex().toLowerCase()).toBe(hex) }) + it('accepts 0x-prefixed hex', () => { + const hex = 'a1286dd195e2b8e1f6bdc946c56a53e0c544750d6452ddc0f4c593ef311f21af' + expect(parseIdentityPrivateKey(`0x${hex}`, 'testnet').hex().toLowerCase()).toBe(hex) + }) + it('accepts WIF', () => { const wif = 'cPGCETHtoevguQoyTSdsowCEF91yqhrcikcvBNK2CuTwpSLV7m9Z' expect(parseIdentityPrivateKey(wif, 'testnet').hex()).toHaveLength(64) }) it('rejects malformed values', () => { - expect(() => parseIdentityPrivateKey('not-a-private-key', 'testnet')).toThrow(/64-character hex or WIF/) + expect(() => parseIdentityPrivateKey('not-a-private-key', 'testnet')).toThrow(/64-character hex/) }) }) diff --git a/tests/unit/identityListing.test.ts b/tests/unit/identityListing.test.ts new file mode 100644 index 00000000..c1863884 --- /dev/null +++ b/tests/unit/identityListing.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it, vi} from 'vitest' +import type {DashPlatformSDK} from 'dash-platform-sdk' +import {WalletService} from '../../src/main/src/services/WalletService' +import type {WalletDAO} from '../../src/main/src/database/WalletDAO' +import type {AddressDAO} from '../../src/main/src/database/AddressDAO' +import type {IdentityDAO} from '../../src/main/src/database/IdentityDAO' +import type {TransactionDAO} from '../../src/main/src/database/TransactionDAO' +import type {ApplicationService} from '../../src/main/src/services/ApplicationService' +import type {WalletSyncService} from '../../src/main/src/services/WalletSyncService' +import type {SdkProvider} from '../../src/main/src/providers/SdkProvider' +import type {ShieldedService} from '../../src/main/src/services/ShieldedService' + +const WALLET_ID = 'wallet-1' +const IDENTITY_ID = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF' + +describe('WalletService.getIdentities', () => { + it('keeps a valid imported identity visible when its optional DPNS lookup fails', async () => { + const walletDAO = { + getWalletById: vi.fn().mockResolvedValue({ + walletId: WALLET_ID, + network: 'mainnet', + }), + } as unknown as WalletDAO + const identityDAO = { + getIdentitiesByWalletId: vi.fn().mockResolvedValue([{ + walletId: WALLET_ID, + identityIndex: -1, + derivationPath: '', + identifier: IDENTITY_ID, + assetLockTxid: null, + isImported: true, + }]), + } as unknown as IdentityDAO + const sdk = { + identities: { + getIdentityByIdentifier: vi.fn().mockResolvedValue({ + id: {base58: () => IDENTITY_ID}, + balance: 42_000_000n, + }), + }, + names: { + searchByIdentity: vi.fn().mockRejectedValue(new Error('DPNS unavailable')), + }, + } as unknown as DashPlatformSDK + const sdkProvider = { + getPlatformSDK: vi.fn().mockReturnValue(sdk), + } as unknown as SdkProvider + + const service = new WalletService( + walletDAO, + {} as AddressDAO, + identityDAO, + {} as TransactionDAO, + {} as ApplicationService, + {} as WalletSyncService, + sdkProvider, + 1_000, + {} as ShieldedService, + ) + + await expect(service.getIdentities(WALLET_ID)).resolves.toEqual([{ + identityIndex: -1, + identifier: IDENTITY_ID, + alias: null, + balance: { + amount: 42_000_000n, + usdAmount: '0.0', + }, + derivationPath: '', + assetLockTxid: null, + isImported: true, + }]) + }) +})