From b818976e86e4afa0761a1dcc15780373a3a9701d Mon Sep 17 00:00:00 2001 From: LexxXell Date: Tue, 29 Sep 2026 15:16:37 +0400 Subject: [PATCH 1/3] feat: fund an identity asset lock from the wallet's own Core coins --- src/content-script/api/PrivateAPI.ts | 9 +- .../private/identities/registerIdentity.ts | 171 ++++++++++---- .../api/private/identities/topUpIdentity.ts | 194 ++++++++++++--- .../wallet/fundPlatformAddressFromCore.ts | 4 + .../AssetLockFundingAddressesRepository.ts | 6 + .../services/CoreAssetLockService.ts | 115 +++++++++ .../services/CoreExplorerService.ts | 44 ++++ src/content-script/storage/storageSchema.ts | 12 +- src/types/CoreUtxo.ts | 10 + .../payloads/RegisterIdentityPayload.ts | 15 +- .../messages/payloads/TopUpIdentityPayload.ts | 9 +- src/utils/buildAssetLockFromUtxos.ts | 151 ++++++++++++ .../identities/registerIdentity.spec.ts | 5 +- .../identities/selfFundedAssetLock.spec.ts | 223 ++++++++++++++++++ .../private/identities/topUpIdentity.spec.ts | 8 +- 15 files changed, 892 insertions(+), 84 deletions(-) create mode 100644 src/content-script/services/CoreAssetLockService.ts create mode 100644 src/types/CoreUtxo.ts create mode 100644 src/utils/buildAssetLockFromUtxos.ts create mode 100644 test/api/private/identities/selfFundedAssetLock.spec.ts diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 3a71eacb..8e7d21c4 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -40,6 +40,7 @@ import { CreateStateTransitionHandler } from './private/stateTransitions/createS import { CreateIdentityPrivateKeyHandler } from './private/identities/createIdentityPrivateKey' import { AssetLockFundingAddressesRepository } from '../repository/AssetLockFundingAddressesRepository' import { CoreExplorerService } from '../services/CoreExplorerService' +import { CoreAssetLockService } from '../services/CoreAssetLockService' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' import { RegisterIdentityHandler } from './private/identities/registerIdentity' @@ -123,6 +124,7 @@ export class PrivateAPI { const assetLockFundingAddressesRepository = new AssetLockFundingAddressesRepository(this.storageAdapter) const walletSettingsRepository = new WalletSettingsRepository(this.storageAdapter) const coreExplorer = new CoreExplorerService() + const coreAssetLock = new CoreAssetLockService(this.sdk, coreExplorer) this.handlers = { [MessagingMethods.GET_STATUS]: new GetStatusHandler(this.storageAdapter, walletRepository), @@ -164,14 +166,17 @@ export class PrivateAPI { assetLockFundingAddressesRepository, this.storageAdapter, this.sdk, - this.coreSDK + this.coreSDK, + coreAssetLock ), [MessagingMethods.TOP_UP_IDENTITY]: new TopUpIdentityHandler( walletRepository, identitiesRepository, assetLockFundingAddressesRepository, this.sdk, - this.coreSDK + this.coreSDK, + coreExplorer, + coreAssetLock ), [MessagingMethods.GET_SETTINGS]: new GetSettingsHandler(walletSettingsRepository), [MessagingMethods.SET_SETTINGS]: new SetSettingsHandler(walletSettingsRepository), diff --git a/src/content-script/api/private/identities/registerIdentity.ts b/src/content-script/api/private/identities/registerIdentity.ts index 34569e73..53dadf36 100644 --- a/src/content-script/api/private/identities/registerIdentity.ts +++ b/src/content-script/api/private/identities/registerIdentity.ts @@ -1,4 +1,4 @@ -import { DashCoreSDK } from 'dash-core-sdk' +import { DashCoreSDK, Transaction } from 'dash-core-sdk' import { PrivateKeyWASM } from 'dash-platform-sdk/types' import { DashPlatformSDK } from 'dash-platform-sdk' import { PrivateKey, decrypt } from 'eciesjs' @@ -13,6 +13,9 @@ import { RegisterIdentityPayload } from '../../../../types/messages/payloads/Reg import { RegisterIdentityResponse } from '../../../../types/messages/response/RegisterIdentityResponse' import { IdentityType } from '../../../../types/enums/IdentityType' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' +import { CoreAssetLockService } from '../../../services/CoreAssetLockService' +import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' +import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' import { IDENTITY_KEY_DEFINITIONS, buildIdentityCreateTransition } from '../../../../utils/identityRegistration' import { @@ -30,6 +33,7 @@ import { TXID_HEX_LENGTH, IDENTITY_INDEX_SCAN_LIMIT, REGISTRATION_CONFIRM_TIMEOU export class RegisterIdentityHandler implements APIHandler { walletRepository: WalletRepository + coreAssetLock: CoreAssetLockService identitiesRepository: IdentitiesRepository assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository storageAdapter: StorageAdapter @@ -42,8 +46,10 @@ export class RegisterIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, storageAdapter: StorageAdapter, sdk: DashPlatformSDK, - coreSDK: DashCoreSDK + coreSDK: DashCoreSDK, + coreAssetLock: CoreAssetLockService ) { + this.coreAssetLock = coreAssetLock this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository this.assetLockFundingAddressesRepository = assetLockFundingAddressesRepository @@ -66,35 +72,48 @@ export class RegisterIdentityHandler implements APIHandler { throw new Error('Identity registration is only supported for seedphrase wallets') } - // ── 2. Load the asset lock funding address entry ──────────────────────── - const assetLockFundingAddressEntry = await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) - - if (assetLockFundingAddressEntry == null) { + // ── 2. Load the asset lock record ─────────────────────────────────────── + // Either a deposit the user paid to a one-off address, or - when no address is + // given - the wallet's own coins. In that mode the record is keyed by the + // credit output address and may not exist yet; an unfinished one is picked up + // so a retry continues the same asset lock. + const selfFunded = payload.assetLockFundingAddress == null + let assetLockFundingAddressEntry = selfFunded + ? await this.unfinishedSelfFundedEntry() + : await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress as string) + + if (!selfFunded && assetLockFundingAddressEntry == null) { throw new Error( - `Asset lock funding address ${payload.assetLockFundingAddress} not found. ` + + `Asset lock funding address ${String(payload.assetLockFundingAddress)} not found. ` + 'It may belong to a different wallet or network.' ) } - if (assetLockFundingAddressEntry.used) { - throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used for registration`) + if (assetLockFundingAddressEntry?.used === true) { + throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} has already been used for registration`) } // ── 3. Decrypt the one-time funding key ───────────────────────────────── // The funding key signs the asset lock tx inputs only. Credit output // ownership and Platform ST signing are handled by identityRegistrationKey // derived in step 5 (DIP-0013). - const passwordHash = hash.sha256().update(payload.password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) + // Own coins are signed by BIP44 keys derived from the seed, so there is no + // one-off key to decrypt in that mode. + let assetLockFundingPrivateKey: PrivateKeyWASM | null = null - let assetLockFundingKeyBytes: Uint8Array - try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) - } catch { - throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') - } + if (assetLockFundingAddressEntry?.encryptedPrivateKey != null) { + const passwordHash = hash.sha256().update(payload.password).digest('hex') + const secretKey = PrivateKey.fromHex(passwordHash) + + let assetLockFundingKeyBytes: Uint8Array + try { + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) + } catch { + throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') + } - const assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + } // ── 4. Determine the identity index ───────────────────────────────────── // The credit output address — and therefore the asset lock txid — is derived @@ -105,12 +124,13 @@ export class RegisterIdentityHandler implements APIHandler { // rebuild a different tx than the one already committed on L1. let identityIndex: number - if (assetLockFundingAddressEntry.assetLockTxid == null) { + if (assetLockFundingAddressEntry?.registrationIdentityIndex != null) { + // Recovery: reuse the index pinned for this asset lock. With own coins it is + // pinned when the record is opened, before anything is broadcast. + identityIndex = assetLockFundingAddressEntry.registrationIdentityIndex + } else if (assetLockFundingAddressEntry?.assetLockTxid == null) { // Fresh registration: scan for the next free index on-chain. identityIndex = await this.scanFreeIdentityIndex(wallet, payload.password) - } else if (assetLockFundingAddressEntry.registrationIdentityIndex != null) { - // Recovery: reuse the index pinned when the asset lock was broadcast. - identityIndex = assetLockFundingAddressEntry.registrationIdentityIndex } else { // Legacy recovery: the asset lock was broadcast before the index was // pinned. Recover it by finding the index whose rebuilt asset lock matches @@ -118,7 +138,7 @@ export class RegisterIdentityHandler implements APIHandler { identityIndex = await this.recoverIdentityIndexFromTxid( wallet, payload, - assetLockFundingPrivateKey, + assetLockFundingPrivateKey as PrivateKeyWASM, assetLockFundingAddressEntry.assetLockTxid ) } @@ -130,24 +150,37 @@ export class RegisterIdentityHandler implements APIHandler { const creditOutputAddress = this.creditOutputAddress(identityRegistrationKey, wallet.network) // ── 6. Build asset lock transaction ───────────────────────────────────── - // Inputs are signed by the one-time funding key. Credit output goes to - // creditOutputAddress (registration key). Build is deterministic on retry. - const { assetLockTx } = await buildAssetLockFromFundingTx( - this.coreSDK, - payload.assetLockFundingTxid, - payload.assetLockFundingAddress, - assetLockFundingPrivateKey.WIF(), - creditOutputAddress - ) + // A deposit is spent by its one-time key and rebuilds byte for byte on retry. + // Own coins are selected once, signed, and the signed transaction is stored + // before it is broadcast: the wallet's UTXO set moves, so a retry must send + // that transaction rather than select again. + let assetLockTx: Transaction + + if (selfFunded) { + assetLockTx = assetLockFundingAddressEntry?.assetLockTx != null + ? Transaction.fromHex(assetLockFundingAddressEntry.assetLockTx) + : await this.openSelfFundedAssetLock(wallet, payload, creditOutputAddress, identityIndex) + + assetLockFundingAddressEntry = await this.assetLockFundingAddressesRepository.getByAddress(creditOutputAddress) + } else { + assetLockTx = (await buildAssetLockFromFundingTx( + this.coreSDK, + payload.assetLockFundingTxid as string, + payload.assetLockFundingAddress as string, + (assetLockFundingPrivateKey as PrivateKeyWASM).WIF(), + creditOutputAddress + )).assetLockTx + } + const fundingAddress = selfFunded ? creditOutputAddress : payload.assetLockFundingAddress as string const assetLockTxid = assetLockTx.hash() if ( - assetLockFundingAddressEntry.assetLockTxid != null && + assetLockFundingAddressEntry?.assetLockTxid != null && assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid ) { throw new Error( - `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + + `Asset lock funding address ${fundingAddress} is already broadcasted ` + `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` ) } @@ -157,16 +190,16 @@ export class RegisterIdentityHandler implements APIHandler { // because waitForAssetLockProof needs it to receive instant lock events // for txs that are not yet chain-locked. const instantLockSub = this.coreSDK.subscribeToTransactions( - [payload.assetLockFundingAddress], + [fundingAddress], [txidToFilterBytes(assetLockTxid)] ) - if (assetLockFundingAddressEntry.assetLockTxid == null) { + if (assetLockFundingAddressEntry?.assetLockTxid == null) { await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) // Persist the broadcasted txid AND the identity index before any further // work, so a retry after a crash rebuilds the exact same asset lock instead // of re-scanning to a different index. - await this.assetLockFundingAddressesRepository.markAsBroadcasted(payload.assetLockFundingAddress, assetLockTxid, identityIndex) + await this.assetLockFundingAddressesRepository.markAsBroadcasted(fundingAddress, assetLockTxid, identityIndex) } // ── 8. Wait for instant lock or chain lock (whichever comes first) ────── @@ -259,7 +292,7 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 15. Mark funding address as used and switch identity ──────────────── - await this.assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + await this.assetLockFundingAddressesRepository.markAsUsed(fundingAddress) await this.walletRepository.switchIdentity(identifier) return { @@ -277,6 +310,64 @@ export class RegisterIdentityHandler implements APIHandler { // Address that owns an asset lock credit output for a given registration key // (P2PKH of the DIP-0013 registration key at m/9'/coin'/5'/1'/identityIndex). + // An asset lock this wallet already signed with its own coins and has not + // finished. Such a record has no one-off key, which is what tells it apart from + // a deposit address still waiting for money. + private async unfinishedSelfFundedEntry (): Promise { + const entries = await this.assetLockFundingAddressesRepository.findAllUnused('registration') + + return entries.find(entry => entry.encryptedPrivateKey == null) ?? null + } + + // Selects the wallet's own coins, signs the asset lock and stores it together + // with the identity index - before anything reaches the network. That order is + // what makes a retry safe: the record already names the transaction to send and + // the index whose credit address it pays to. + private async openSelfFundedAssetLock ( + wallet: Wallet, + payload: RegisterIdentityPayload, + creditOutputAddress: string, + identityIndex: number + ): Promise { + if (payload.amountCredits == null) { + throw new Error('Funding an asset lock from your own coins needs an amount in credits') + } + + const amountCredits = BigInt(payload.amountCredits) + + if (amountCredits % 1000n !== 0n) { + throw new Error('Amount must be a whole number of duffs (1000 credits)') + } + + const xpub = await this.coreAssetLock.accountXpub(this.walletRepository, wallet, payload.password) + const [utxos, changeAddress, entries] = await Promise.all([ + this.coreAssetLock.spendableUtxos(xpub, wallet), + this.coreAssetLock.changeAddress(xpub, wallet), + this.assetLockFundingAddressesRepository.getAll() + ]) + + const plan = selectAssetLockUtxos( + utxos, + amountCredits / 1000n, + creditOutputAddress, + changeAddress, + this.coreAssetLock.reservedOutpoints(entries) + ) + + const assetLockTx = await this.coreAssetLock.signPlan(plan, wallet, payload.password) + + await this.assetLockFundingAddressesRepository.create({ + address: creditOutputAddress, + encryptedPrivateKey: null, + used: false, + assetLockTx: assetLockTx.hex(), + registrationIdentityIndex: identityIndex, + purpose: 'registration' + }) + + return assetLockTx + } + private creditOutputAddress (identityRegistrationKey: PrivateKeyWASM, network: Wallet['network']): string { return this.sdk.keyPair.p2pkhAddress(identityRegistrationKey.getPublicKey().bytes(), network as any) } @@ -320,8 +411,8 @@ export class RegisterIdentityHandler implements APIHandler { const { assetLockTx } = await buildAssetLockFromFundingTx( this.coreSDK, - payload.assetLockFundingTxid, - payload.assetLockFundingAddress, + payload.assetLockFundingTxid as string, + payload.assetLockFundingAddress as string, assetLockFundingPrivateKey.WIF(), creditOutputAddress ) diff --git a/src/content-script/api/private/identities/topUpIdentity.ts b/src/content-script/api/private/identities/topUpIdentity.ts index 1418ebf5..6768acee 100644 --- a/src/content-script/api/private/identities/topUpIdentity.ts +++ b/src/content-script/api/private/identities/topUpIdentity.ts @@ -1,5 +1,5 @@ -import { DashCoreSDK } from 'dash-core-sdk' -import { KeyType, PrivateKeyWASM } from 'dash-platform-sdk/types' +import { DashCoreSDK, Transaction } from 'dash-core-sdk' +import { KeyType, Network, PrivateKeyWASM } from 'dash-platform-sdk/types' import { DashPlatformSDK } from 'dash-platform-sdk' import { PrivateKey, decrypt } from 'eciesjs' import hash from 'hash.js' @@ -11,10 +11,14 @@ import { AssetLockFundingAddressesRepository } from '../../../repository/AssetLo import { TopUpIdentityPayload } from '../../../../types/messages/payloads/TopUpIdentityPayload' import { TopUpIdentityResponse } from '../../../../types/messages/response/TopUpIdentityResponse' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' +import { CoreAssetLockService } from '../../../services/CoreAssetLockService' +import { CoreExplorerService } from '../../../services/CoreExplorerService' +import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' +import { Wallet } from '../../../../types/Wallet' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' -import { hexToBytes } from '../../../../utils' +import { deriveTopUpKeyFromHdKey, deriveWalletHdKey, hexToBytes } from '../../../../utils' import { txidToFilterBytes } from '../../../../utils/txidToFilterBytes' -import { TXID_HEX_LENGTH } from '../../../../constants' +import { TOPUP_FUNDING_GAP_LIMIT, TXID_HEX_LENGTH } from '../../../../constants' import { isIdempotentTopUpError } from '../../../../utils/isIdempotentTopUpError' import { RepositoryScope } from '../../../../types/RepositoryScope' import { validateRepositoryScopePayload } from '../../../../utils/validateRepositoryScopePayload' @@ -25,14 +29,20 @@ export class TopUpIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository sdk: DashPlatformSDK coreSDK: DashCoreSDK + coreExplorer: CoreExplorerService + coreAssetLock: CoreAssetLockService constructor ( walletRepository: WalletRepository, identitiesRepository: IdentitiesRepository, assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, sdk: DashPlatformSDK, - coreSDK: DashCoreSDK + coreSDK: DashCoreSDK, + coreExplorer: CoreExplorerService, + coreAssetLock: CoreAssetLockService ) { + this.coreExplorer = coreExplorer + this.coreAssetLock = coreAssetLock this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository this.assetLockFundingAddressesRepository = assetLockFundingAddressesRepository @@ -69,62 +79,104 @@ export class TopUpIdentityHandler implements APIHandler { throw new Error(`Identity ${payload.identityId} does not belong to wallet ${scope.walletId} on ${scope.network}`) } - const assetLockFundingAddressEntry = await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) - - if (assetLockFundingAddressEntry == null) { - throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} not found`) + // With no address given the wallet pays with its own coins: the record is keyed + // by the credit output address of the DIP-13 top-up key, and an unfinished one + // for this identity is picked up so a retry continues the same asset lock. + const selfFunded = payload.assetLockFundingAddress == null + let assetLockFundingAddressEntry = selfFunded + ? (await assetLockFundingAddressesRepository.findAllUnused('topUp', payload.identityId)) + .find(entry => entry.encryptedPrivateKey == null) ?? null + : await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress as string) + + if (!selfFunded && assetLockFundingAddressEntry == null) { + throw new Error(`Asset lock funding address ${String(payload.assetLockFundingAddress)} not found`) } - if (assetLockFundingAddressEntry.used) { - throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used`) + if (assetLockFundingAddressEntry?.used === true) { + throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} has already been used`) } // An address reserved for another identity is refused rather than spent // toward this one, which would consume the deposit the other top-up is // waiting on. Entries with no owner predate per-identity reservation. if ( - assetLockFundingAddressEntry.identityId != null && + assetLockFundingAddressEntry?.identityId != null && assetLockFundingAddressEntry.identityId !== payload.identityId ) { throw new Error( - `Asset lock funding address ${payload.assetLockFundingAddress} is reserved ` + + `Asset lock funding address ${assetLockFundingAddressEntry.address} is reserved ` + `for identity ${assetLockFundingAddressEntry.identityId}` ) } - const passwordHash = hash.sha256().update(payload.password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) + // A deposit keeps its one-off key in the record; own coins derive the DIP-13 + // top-up key (m/9'/coin'/5'/2'/index) from the seed instead. Either way this is + // the key that owns the credit output and signs the top-up transition. + let assetLockFundingPrivateKey: PrivateKeyWASM + let topUpIndex = assetLockFundingAddressEntry?.index + + if (assetLockFundingAddressEntry?.encryptedPrivateKey != null) { + const passwordHash = hash.sha256().update(payload.password).digest('hex') + const secretKey = PrivateKey.fromHex(passwordHash) + + let assetLockFundingKeyBytes: Uint8Array + try { + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) + } catch { + throw new Error('Failed to decrypt asset lock funding key - wrong password or corrupted entry') + } - let assetLockFundingKeyBytes: Uint8Array - try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) - } catch { - throw new Error('Failed to decrypt asset lock funding key - wrong password or corrupted entry') + assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + } else { + const walletHdKey = deriveWalletHdKey(wallet, payload.password, this.sdk) + + topUpIndex = topUpIndex ?? await this.freeTopUpIndex(walletHdKey, wallet, assetLockFundingAddressesRepository) + assetLockFundingPrivateKey = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, topUpIndex, this.sdk) } - const assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + const creditOutputAddress = selfFunded + ? this.sdk.keyPair.p2pkhAddress(assetLockFundingPrivateKey.getPublicKey().bytes(), wallet.network as Network) + : payload.assetLockFundingAddress as string // Build asset lock transaction. The build is deterministic so the same // inputs produce the same txid on retry. For a top-up the funding key both // funds the asset lock and owns the credit output (it signs the top-up // state transition below), so the credit output goes back to the funding // address — unlike registration, where a separate derived key owns it. - const { assetLockTx, lockedAmount } = await buildAssetLockFromFundingTx( - this.coreSDK, - payload.assetLockFundingTxid, - payload.assetLockFundingAddress, - assetLockFundingPrivateKey.WIF(), - payload.assetLockFundingAddress - ) + let assetLockTx: Transaction + let lockedAmount: bigint + + if (selfFunded) { + assetLockTx = assetLockFundingAddressEntry?.assetLockTx != null + ? Transaction.fromHex(assetLockFundingAddressEntry.assetLockTx) + : await this.openSelfFundedAssetLock(wallet, payload, creditOutputAddress, topUpIndex as number, assetLockFundingAddressesRepository) + + assetLockFundingAddressEntry = await assetLockFundingAddressesRepository.getByAddress(creditOutputAddress) + // The asset lock's own output carries the locked amount, so a resumed + // operation reads it off the stored transaction instead of the payload. + lockedAmount = assetLockTx.outputs[0].satoshis + } else { + const built = await buildAssetLockFromFundingTx( + this.coreSDK, + payload.assetLockFundingTxid as string, + payload.assetLockFundingAddress as string, + assetLockFundingPrivateKey.WIF(), + payload.assetLockFundingAddress as string + ) + + assetLockTx = built.assetLockTx + lockedAmount = built.lockedAmount + } + const fundingAddress = creditOutputAddress const assetLockTxid = assetLockTx.hash() if ( - assetLockFundingAddressEntry.assetLockTxid != null && + assetLockFundingAddressEntry?.assetLockTxid != null && assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid ) { throw new Error( - `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + + `Asset lock funding address ${fundingAddress} is already broadcasted ` + `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` ) } @@ -133,15 +185,15 @@ export class TopUpIdentityHandler implements APIHandler { // because waitForAssetLockProof needs it to receive instant lock events // for txs that are not yet chain-locked. const instantLockSub = this.coreSDK.subscribeToTransactions( - [payload.assetLockFundingAddress], + [fundingAddress], [txidToFilterBytes(assetLockTxid)] ) - if (assetLockFundingAddressEntry.assetLockTxid == null) { + if (assetLockFundingAddressEntry?.assetLockTxid == null) { await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) // Persist the broadcasted txid before any further work so a crash leaves // a recoverable record of the L1-committed asset lock. - await assetLockFundingAddressesRepository.markAsBroadcasted(payload.assetLockFundingAddress, assetLockTxid) + await assetLockFundingAddressesRepository.markAsBroadcasted(fundingAddress, assetLockTxid) } const assetLockProof = await waitForAssetLockProof( @@ -170,7 +222,7 @@ export class TopUpIdentityHandler implements APIHandler { } } - await assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + await assetLockFundingAddressesRepository.markAsUsed(fundingAddress) return { identityId: payload.identityId, @@ -179,6 +231,80 @@ export class TopUpIdentityHandler implements APIHandler { } } + // First DIP-13 top-up index whose address has never appeared on L1 and is not + // claimed by a local record - the same gap scan the deposit flow uses to hand out + // an address, so the two never land on the same index. + private async freeTopUpIndex ( + walletHdKey: Awaited>, + wallet: Wallet, + assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository + ): Promise { + for (let index = 0; index < TOPUP_FUNDING_GAP_LIMIT; index++) { + const candidate = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, index, this.sdk) + const address = this.sdk.keyPair.p2pkhAddress(candidate.getPublicKey().bytes(), wallet.network as Network) + + if (await assetLockFundingAddressesRepository.getByAddress(address) != null) { + continue + } + + if (!await this.coreExplorer.isAddressUsed(address, wallet.network)) { + return index + } + } + + throw new Error(`No unused top-up funding index found within ${TOPUP_FUNDING_GAP_LIMIT} indexes`) + } + + // Selects the wallet's own coins, signs the asset lock and stores it with the + // top-up index before anything reaches the network, so a retry sends that same + // transaction instead of selecting again. + private async openSelfFundedAssetLock ( + wallet: Wallet, + payload: TopUpIdentityPayload, + creditOutputAddress: string, + topUpIndex: number, + assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository + ): Promise { + if (payload.amountCredits == null) { + throw new Error('Funding an asset lock from your own coins needs an amount in credits') + } + + const amountCredits = BigInt(payload.amountCredits) + + if (amountCredits % 1000n !== 0n) { + throw new Error('Amount must be a whole number of duffs (1000 credits)') + } + + const xpub = await this.coreAssetLock.accountXpub(this.walletRepository, wallet, payload.password) + const [utxos, changeAddress, entries] = await Promise.all([ + this.coreAssetLock.spendableUtxos(xpub, wallet), + this.coreAssetLock.changeAddress(xpub, wallet), + assetLockFundingAddressesRepository.getAll() + ]) + + const plan = selectAssetLockUtxos( + utxos, + amountCredits / 1000n, + creditOutputAddress, + changeAddress, + this.coreAssetLock.reservedOutpoints(entries) + ) + + const assetLockTx = await this.coreAssetLock.signPlan(plan, wallet, payload.password) + + await assetLockFundingAddressesRepository.create({ + address: creditOutputAddress, + encryptedPrivateKey: null, + used: false, + assetLockTx: assetLockTx.hex(), + index: topUpIndex, + purpose: 'topUp', + identityId: payload.identityId + }) + + return assetLockTx + } + // The pair this operation runs against: taken from the payload when the caller // names it, otherwise snapshotted from the current selection. Both SDKs are // fixed to a network for the lifetime of the document that built them — diff --git a/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts b/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts index dfeda49f..5e45aa5d 100644 --- a/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts +++ b/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts @@ -66,6 +66,10 @@ export class FundPlatformAddressFromCoreHandler implements APIHandler { const passwordHash = hash.sha256().update(payload.password).digest('hex') const secretKey = PrivateKey.fromHex(passwordHash) + if (assetLockFundingAddressEntry.encryptedPrivateKey == null) { + throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} is funded from the wallet's own coins and has no one-off key`) + } + let assetLockFundingKeyBytes: Uint8Array try { assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) diff --git a/src/content-script/repository/AssetLockFundingAddressesRepository.ts b/src/content-script/repository/AssetLockFundingAddressesRepository.ts index 3a82c7c1..8296c73b 100644 --- a/src/content-script/repository/AssetLockFundingAddressesRepository.ts +++ b/src/content-script/repository/AssetLockFundingAddressesRepository.ts @@ -114,6 +114,12 @@ export class AssetLockFundingAddressesRepository { // Entries with no owner still match: they predate per-identity reservation, and // may already hold a deposit, so the caller reuses and claims them rather than // stranding the money. + async getAll (): Promise { + const storageKey = await this.getStorageKey() + + return Object.values((await this.storageAdapter.get(storageKey) ?? {}) as AssetLockFundingAddressesSchema) + } + async findAllUnused (purpose: AssetLockFundingPurpose = 'registration', identityId?: string): Promise { const storageKey = await this.getStorageKey() const addresses = (await this.storageAdapter.get(storageKey) ?? {}) as AssetLockFundingAddressesSchema diff --git a/src/content-script/services/CoreAssetLockService.ts b/src/content-script/services/CoreAssetLockService.ts new file mode 100644 index 00000000..f9917aff --- /dev/null +++ b/src/content-script/services/CoreAssetLockService.ts @@ -0,0 +1,115 @@ +import { DashPlatformSDK } from 'dash-platform-sdk' +import { PrivateKey, Transaction } from 'dash-core-sdk' +import { WalletRepository } from '../repository/WalletRepository' +import { CoreExplorerService } from './CoreExplorerService' +import { Wallet } from '../../types/Wallet' +import { CoreUtxo } from '../../types/CoreUtxo' +import { CoreAddressChain } from '../../types/enums/CoreAddressChain' +import { AssetLockFundingAddressSchema } from '../storage/storageSchema' +import { deriveWalletHdKey } from '../../utils' +import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' +import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' + +// Primitives for funding an asset lock with the wallet's own Core coins instead of +// a deposit to a one-off address: the spendable outputs, where change goes, which +// coins another unfinished asset lock already claimed, and the signing. The +// handlers decide when each one runs. +export class CoreAssetLockService { + sdk: DashPlatformSDK + explorer: CoreExplorerService + + constructor (sdk: DashPlatformSDK, explorer: CoreExplorerService) { + this.sdk = sdk + this.explorer = explorer + } + + // The account xpub, cached on first use so later reads need no password. An xpub + // that does not match this seed belongs to another wallet and is never + // overwritten silently. + async accountXpub (walletRepository: WalletRepository, wallet: Wallet, password: string): Promise { + const stored = await walletRepository.getCoreAccountXpub(0) + const derived = await deriveCoreAccountXpub(wallet, password, 0, this.sdk) + + if (stored != null && stored !== derived) { + throw new Error('Core xpub does not belong to this seed') + } + + if (stored == null) { + await walletRepository.setCoreAccountXpub(0, derived) + } + + return derived + } + + // Everything the account can spend right now, read by xpub so it covers addresses + // this install never derived. Each output is matched back to the address entry it + // belongs to, which carries the derivation path the signing needs; an output on an + // address beyond the window is left out rather than guessed at. + async spendableUtxos (xpub: string, wallet: Wallet): Promise { + const utxos = await this.explorer.getXpubUtxos(xpub, wallet.network) + const derived = new Map(this.accountAddresses(xpub, wallet).map(entry => [entry.address, entry])) + + return utxos + .filter(utxo => derived.has(utxo.address)) + .map(utxo => ({ + ...(derived.get(utxo.address) as CoreAddressEntry), + txid: utxo.txid, + vout: utxo.vout, + amount: utxo.amount.toString() + })) + } + + // Both chains of the account, as far out as the wallet hands addresses. + private accountAddresses (xpub: string, wallet: Wallet): CoreAddressEntry[] { + return [CoreAddressChain.receiving, CoreAddressChain.change].flatMap(chain => + deriveCoreAddressesFromXpub(this.sdk, xpub, wallet.network, 0, chain, CORE_ADDRESS_WINDOW)) + } + + // Change goes to the next change address the explorer has not seen used, so two + // asset locks in a row do not pay themselves to the same one. + async changeAddress (xpub: string, wallet: Wallet): Promise { + const { nextUnused } = await this.explorer.getXpubSummary(xpub, wallet.network) + + return deriveCoreAddressesFromXpub(this.sdk, xpub, wallet.network, 0, CoreAddressChain.change, 1, nextUnused.change)[0].address + } + + // Outpoints an unfinished asset lock has already signed. Its transaction may + // still reach the network, so a new one must not spend the same coins. + reservedOutpoints (entries: AssetLockFundingAddressSchema[]): Set { + const reserved = new Set() + + for (const entry of entries) { + if (entry.used || entry.assetLockTx == null) { + continue + } + + for (const input of Transaction.fromHex(entry.assetLockTx).inputs) { + reserved.add(`${input.getTxIdHex()}:${input.vOut}`) + } + } + + return reserved + } + + // Signs the planned asset lock with one BIP44 key per input, derived from the + // seed at the path the explorer reported for that address. + async signPlan (plan: CoreAssetLockPlan, wallet: Wallet, password: string): Promise { + const root = deriveWalletHdKey(wallet, password, this.sdk) + const keys: PrivateKey[] = [] + + for (const input of plan.inputs) { + const child = await this.sdk.keyPair.derivePath(root, input.derivationPath) + + if (child.privateKey == null) { + throw new Error(`Could not derive the key for Core input address ${input.address}`) + } + + keys.push(PrivateKey.fromBytes(child.privateKey, wallet.network)) + } + + return buildAssetLockFromUtxos(plan, keys) + } +} + +// How far along each chain an address is still considered the wallet's own. +const CORE_ADDRESS_WINDOW = 100 diff --git a/src/content-script/services/CoreExplorerService.ts b/src/content-script/services/CoreExplorerService.ts index 42755b01..b590d4e8 100644 --- a/src/content-script/services/CoreExplorerService.ts +++ b/src/content-script/services/CoreExplorerService.ts @@ -26,6 +26,11 @@ export interface CoreAddressUtxo { amount: bigint } +// An account output, with the address that received it. +export interface CoreXpubUtxo extends CoreAddressUtxo { + address: string +} + export interface CoreExplorerTransactionInput { // null for a coinbase input, which spends no address address: string | null @@ -59,6 +64,9 @@ export interface CoreExplorerTransactionsPage { nextCursor: string | null } +// Largest page the explorer serves for its /xpub list endpoints. +const XPUB_PAGE_LIMIT = 100 + const getBaseUrl = (network: NetworkType = 'testnet'): string => { return CORE_EXPLORER_URLS[network].api } @@ -241,6 +249,42 @@ export class CoreExplorerService { return info != null && info.txCount > 0 } + // Every confirmed output the account can spend, across both of the xpub's + // chains, so an asset lock can be funded without deriving addresses locally and + // asking about each one. Paged: the explorer caps a page at XPUB_PAGE_LIMIT and + // reports the total, which is how the walk knows it is done. + async getXpubUtxos (xpub: string, network: NetworkType = 'testnet'): Promise { + const baseUrl = getBaseUrl(network) + const utxos: CoreXpubUtxo[] = [] + + let fetched = 0 + for (let page = 1; ; page++) { + const response = await fetch(`${baseUrl}/xpub/utxo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ xpub, page, limit: XPUB_PAGE_LIMIT }) + }) + + if (!response.ok) { + throw new Error(`Core explorer error for xpub utxo: HTTP ${response.status}`) + } + + const data = await response.json() + const rows: any[] = Array.isArray(data?.resultSet) ? data.resultSet : [] + + fetched += rows.length + for (const row of rows) { + if (typeof row.address === 'string' && typeof row.prevTxHash === 'string') { + utxos.push({ address: row.address, txid: row.prevTxHash, vout: toCount(row.vOutIndex), amount: toBigInt(row.amount) }) + } + } + + if (rows.length === 0 || fetched >= toCount(data?.pagination?.total)) { + return utxos + } + } + } + // Confirmed UTXOs for an address. Empty when the address is unseen or has no // spendable outputs. Used for recovery, not for first-deposit detection. async getAddressUtxos (address: string, network: NetworkType = 'testnet'): Promise { diff --git a/src/content-script/storage/storageSchema.ts b/src/content-script/storage/storageSchema.ts index 242cc4aa..0b85fc68 100644 --- a/src/content-script/storage/storageSchema.ts +++ b/src/content-script/storage/storageSchema.ts @@ -77,10 +77,20 @@ export interface AppConnectsStorageSchema { export type AssetLockFundingPurpose = 'registration' | 'topUp' export interface AssetLockFundingAddressSchema { + // The address this entry is keyed by: the one-off deposit address for a funded + // asset lock, or the credit output address when the wallet pays with its own + // coins (nothing is deposited, so there is no deposit address to name). address: string - encryptedPrivateKey: string + // Null when the wallet pays with its own coins: the inputs are signed by BIP44 + // keys derived from the seed, so there is no one-off key to keep. + encryptedPrivateKey: string | null used: boolean assetLockTxid?: string | null + // The signed asset lock, hex, stored before it is broadcast when the wallet pays + // with its own coins. A deposit has a single input and rebuilds byte for byte, but + // a selection over the wallet's own UTXOs would change as soon as one of them is + // spent elsewhere, and a retry must send the same transaction, not a second one. + assetLockTx?: string // DIP-13 derivation index for top-up funding keys (m/9'/coin'/5'/2'/index). // Absent for registration entries, which use a one-time random funding key. index?: number diff --git a/src/types/CoreUtxo.ts b/src/types/CoreUtxo.ts new file mode 100644 index 00000000..f5af02a8 --- /dev/null +++ b/src/types/CoreUtxo.ts @@ -0,0 +1,10 @@ +import { CoreAddressEntry } from '../utils/coreAddresses' + +export interface CoreUtxo extends CoreAddressEntry { + txid: string + vout: number + amount: string + confirmations?: number + isInstantLocked?: boolean + isChainLocked?: boolean +} diff --git a/src/types/messages/payloads/RegisterIdentityPayload.ts b/src/types/messages/payloads/RegisterIdentityPayload.ts index 0c240ab8..639ea057 100644 --- a/src/types/messages/payloads/RegisterIdentityPayload.ts +++ b/src/types/messages/payloads/RegisterIdentityPayload.ts @@ -1,8 +1,17 @@ export interface RegisterIdentityPayload { - /** The asset lock funding P2PKH address that received the payment */ - assetLockFundingAddress: string + /** + * The asset lock funding P2PKH address that received the payment. Omit it, with + * the txid, to fund the asset lock from the wallet's own Core coins instead. + */ + assetLockFundingAddress?: string /** Txid of the asset lock funding transaction that paid to the address */ - assetLockFundingTxid: string + assetLockFundingTxid?: string /** Extension password used to decrypt the asset lock funding private key */ password: string + /** + * How many credits to lock, when the wallet pays with its own coins. A deposit + * locks whatever it received, so this belongs to that mode only. Credits, as a + * string; must be a whole number of duffs (1000 credits). + */ + amountCredits?: string } diff --git a/src/types/messages/payloads/TopUpIdentityPayload.ts b/src/types/messages/payloads/TopUpIdentityPayload.ts index 4acd48aa..89b8cbd8 100644 --- a/src/types/messages/payloads/TopUpIdentityPayload.ts +++ b/src/types/messages/payloads/TopUpIdentityPayload.ts @@ -2,9 +2,14 @@ import { NetworkType } from '../../NetworkType' export interface TopUpIdentityPayload { identityId: string - assetLockFundingAddress: string - assetLockFundingTxid: string + // Omit both, with an amount instead, to fund the asset lock from the wallet's + // own Core coins rather than a deposit to a one-off address. + assetLockFundingAddress?: string + assetLockFundingTxid?: string password: string + // Credits to lock when the wallet pays with its own coins; a deposit locks + // whatever it received. Must be a whole number of duffs (1000 credits). + amountCredits?: string // The (network, wallet) the top-up is bound to. Optional: callers that omit // them get the extension's current selection, snapshotted once when the // handler starts. A long-lived caller (the top-up tab, which stays open across diff --git a/src/utils/buildAssetLockFromUtxos.ts b/src/utils/buildAssetLockFromUtxos.ts new file mode 100644 index 00000000..3f683356 --- /dev/null +++ b/src/utils/buildAssetLockFromUtxos.ts @@ -0,0 +1,151 @@ +import { ExtraPayload, Input, Output, PrivateKey, Script, Transaction, TransactionType } from 'dash-core-sdk' +import { CoreUtxo } from '../types/CoreUtxo' + +// Conservative P2PKH input size: outpoint, compact length, 108-byte script, sequence. +const SIGNED_INPUT_SIZE = 149 +// Below this an output costs more to spend than it holds, so change is dropped +// into the fee instead of being created. +const DUST_DUFFS = 546n +export const CORE_ASSET_LOCK_FEE_PER_BYTE = 1n + +// What an asset lock will spend and pay, decided before anything is signed. The +// credit output is the DIP-13 address that owns the locked credits; the change +// goes back to the wallet's own change chain. +export interface CoreAssetLockPlan { + inputs: CoreUtxo[] + amountDuffs: string + feeDuffs: string + changeDuffs: string + changeAddress: string + creditOutputAddress: string +} + +const transaction = (plan: CoreAssetLockPlan): Transaction => { + const amount = BigInt(plan.amountDuffs) + const outputs = [new Output(amount, Script.fromASM('OP_RETURN OP_0'))] + + if (BigInt(plan.changeDuffs) > 0n) { + outputs.push(Output.createP2PKH(BigInt(plan.changeDuffs), plan.changeAddress)) + } + + return new Transaction( + plan.inputs.map(input => new Input(input.txid, input.vout, Output.createP2PKH(0n, input.address).script, 0xffffffff)), + outputs, + 0, + undefined, + TransactionType.TRANSACTION_ASSET_LOCK, + new ExtraPayload.AssetLockTx(1, 1, [Output.createP2PKH(amount, plan.creditOutputAddress)]) + ) +} + +const estimateFee = (plan: CoreAssetLockPlan, rate: bigint): bigint => { + const tx = transaction(plan) + // Replace each unsigned script with the largest signed P2PKH input. The + // serialized tx already accounts for compact counts and the special payload. + const size = tx.bytes().length + tx.inputs.reduce((sum, input) => sum + SIGNED_INPUT_SIZE - input.bytes().length, 0) + + return BigInt(size) * rate +} + +// Picks the wallet's own UTXOs for an asset lock of `amountDuffs`, largest first, +// recomputing the fee as each input is added. `reserved` holds outpoints an +// unfinished asset lock already committed to, so two of them never spend the same +// coin. Throws rather than silently funding less than asked. +export const selectAssetLockUtxos = ( + utxos: CoreUtxo[], + amountDuffs: bigint, + creditOutputAddress: string, + changeAddress: string, + reserved: Set = new Set(), + feePerByte: bigint = CORE_ASSET_LOCK_FEE_PER_BYTE +): CoreAssetLockPlan => { + if (amountDuffs <= 0n || amountDuffs > 21000000n * 100000000n) { + throw new Error('Invalid asset lock amount') + } + if (feePerByte <= 0n) { + throw new Error('Invalid Core fee rate') + } + + const seen = new Set() + const available = utxos.filter(input => { + const outpoint = `${input.txid}:${input.vout}` + const malformed = !/^[a-fA-F0-9]{64}$/.test(input.txid) || + !Number.isSafeInteger(input.vout) || input.vout < 0 || input.vout > 0xffffffff || + !/^\d+$/.test(input.amount) || BigInt(input.amount) <= 0n + + if (malformed) { + throw new Error('Invalid Core UTXO') + } + if (seen.has(outpoint)) { + throw new Error('Duplicate Core outpoint') + } + + seen.add(outpoint) + + return !reserved.has(outpoint) + }).sort((left, right) => { + if (BigInt(left.amount) === BigInt(right.amount)) { + return `${left.txid}:${left.vout}`.localeCompare(`${right.txid}:${right.vout}`) + } + + return BigInt(left.amount) > BigInt(right.amount) ? -1 : 1 + }) + + const plan: CoreAssetLockPlan = { + inputs: [], + amountDuffs: amountDuffs.toString(), + feeDuffs: '0', + changeDuffs: '0', + creditOutputAddress, + changeAddress + } + + let total = 0n + + for (const input of available) { + plan.inputs.push(input) + total += BigInt(input.amount) + plan.changeDuffs = '0' + + if (total < amountDuffs + estimateFee(plan, feePerByte)) { + continue + } + + // Enough with no change; see whether the leftover is worth an output at all. + plan.changeDuffs = DUST_DUFFS.toString() + const change = total - amountDuffs - estimateFee(plan, feePerByte) + + plan.changeDuffs = change >= DUST_DUFFS ? change.toString() : '0' + plan.feeDuffs = (total - amountDuffs - BigInt(plan.changeDuffs)).toString() + + return plan + } + + throw new Error('Insufficient spendable Core balance for this amount plus fee') +} + +// Signs the planned asset lock, one key per input in the same order. Each key is +// checked against the address it spends, so a derivation that drifted cannot sign +// someone else's coin, and the amounts are checked to balance before signing. +export const buildAssetLockFromUtxos = (plan: CoreAssetLockPlan, keys: PrivateKey[]): Transaction => { + if (keys.length !== plan.inputs.length || keys.length === 0) { + throw new Error('One Core key is required per input') + } + + plan.inputs.forEach((input, index) => { + if (keys[index].getAddress() !== input.address) { + throw new Error('Core input does not belong to its derived key') + } + }) + + const total = plan.inputs.reduce((sum, input) => sum + BigInt(input.amount), 0n) + + if (total !== BigInt(plan.amountDuffs) + BigInt(plan.changeDuffs) + BigInt(plan.feeDuffs)) { + throw new Error('Core transaction amounts do not balance') + } + + const tx = transaction(plan) + tx.sign(keys) + + return tx +} diff --git a/test/api/private/identities/registerIdentity.spec.ts b/test/api/private/identities/registerIdentity.spec.ts index 4294f853..2cc4a3c3 100644 --- a/test/api/private/identities/registerIdentity.spec.ts +++ b/test/api/private/identities/registerIdentity.spec.ts @@ -56,6 +56,7 @@ describe('RegisterIdentityHandler', () => { let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any + let coreAssetLock: any let sdk: any let handler: RegisterIdentityHandler let encryptedPrivateKey: string @@ -121,6 +122,7 @@ describe('RegisterIdentityHandler', () => { }) } + coreAssetLock = {} as any coreSDK = { subscribeToTransactions: jest.fn(() => { order.push('subscribe') @@ -175,7 +177,8 @@ describe('RegisterIdentityHandler', () => { assetLockFundingAddressesRepository, {} as any, sdk, - coreSDK + coreSDK, + coreAssetLock ) }) diff --git a/test/api/private/identities/selfFundedAssetLock.spec.ts b/test/api/private/identities/selfFundedAssetLock.spec.ts new file mode 100644 index 00000000..1820c4d1 --- /dev/null +++ b/test/api/private/identities/selfFundedAssetLock.spec.ts @@ -0,0 +1,223 @@ +import { PrivateKeyWASM } from 'dash-platform-sdk/types' +import { Transaction } from 'dash-core-sdk' +import { RegisterIdentityHandler } from '../../../../src/content-script/api/private/identities/registerIdentity' +import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' +import { WalletType } from '../../../../src/types' + +jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ + waitForAssetLockProof: jest.fn() +})) + +jest.mock('../../../../src/utils/identityRegistration', () => ({ + IDENTITY_KEY_DEFINITIONS: [{ id: 0 }], + buildIdentityCreateTransition: jest.fn() +})) + +jest.mock('../../../../src/utils', () => { + const actual = jest.requireActual('../../../../src/utils') + return { + ...actual, + deriveIdentityPrivateKey: jest.fn(), + deriveIdentityRegistrationKey: jest.fn() + } +}) + +const waitForAssetLockProofMock = waitForAssetLockProof as jest.MockedFunction +const { buildIdentityCreateTransition } = jest.requireMock('../../../../src/utils/identityRegistration') +const { deriveIdentityRegistrationKey, deriveIdentityPrivateKey } = jest.requireMock('../../../../src/utils') + +// An asset lock funded from the wallet's own Core coins: no deposit, no one-off +// key. The pipeline is the one the deposit flow uses; only the transaction's +// inputs and the bookkeeping around them differ, so that is what this covers. +describe('RegisterIdentityHandler funded from the wallet own coins', () => { + const identifier = 'HT3pUBM1Uv2mKgdPEN1gxa7A4PdsvNY89aJbdSKQb5wR' + // Real testnet addresses: the fee estimate builds an actual transaction. + const creditOutputAddress = 'yjLG5HeifV72L78cr6EW4sEC9AATZnmLXA' + const password = 'test' + const assetLockTxid = 'b'.repeat(64) + + let stored: any[] + let walletRepository: any + let identitiesRepository: any + let assetLockFundingAddressesRepository: any + let coreSDK: any + let coreAssetLock: any + let sdk: any + let signedTx: any + let handler: RegisterIdentityHandler + + beforeEach(() => { + jest.clearAllMocks() + stored = [] + + signedTx = { + hash: () => assetLockTxid, + hex: () => 'signedassetlockhex', + bytes: () => Uint8Array.from([1, 2, 3]) + } + + // The stored bytes are this stub, so parsing them back is stubbed as well; + // what matters here is that the stored transaction is the one that gets sent. + jest.spyOn(Transaction, 'fromHex').mockReturnValue(signedTx) + + walletRepository = { + getCurrent: jest.fn(async () => ({ + walletId: 'wallet1', + network: 'testnet', + type: WalletType.seedphrase, + encryptedMnemonic: 'encryptedMnemonic', + seedHash: null, + label: null, + currentIdentity: null + })), + switchIdentity: jest.fn(async () => {}), + getCoreAccountXpub: jest.fn(async () => 'xpub'), + setCoreAccountXpub: jest.fn(async () => {}) + } + + identitiesRepository = { + getByIdentifier: jest.fn(async () => null), + create: jest.fn(async () => ({ identifier })), + remove: jest.fn(async () => {}), + getAll: jest.fn(async () => []) + } + + assetLockFundingAddressesRepository = { + findAllUnused: jest.fn(async () => stored.filter(entry => entry.used !== true)), + getByAddress: jest.fn(async (address: string) => stored.find(entry => entry.address === address) ?? null), + getAll: jest.fn(async () => stored), + create: jest.fn(async (entry: any) => { + stored.push(entry) + return entry + }), + markAsBroadcasted: jest.fn(async (address: string, txid: string, index?: number) => { + const entry = stored.find(candidate => candidate.address === address) + entry.assetLockTxid = txid + entry.registrationIdentityIndex = index + }), + markAsUsed: jest.fn(async (address: string) => { + stored.find(candidate => candidate.address === address).used = true + }) + } + + coreSDK = { + broadcastTransaction: jest.fn(async () => {}), + subscribeToTransactions: jest.fn(() => ({ async * [Symbol.asyncIterator] () {} })) + } + + coreAssetLock = { + accountXpub: jest.fn(async () => 'xpub'), + spendableUtxos: jest.fn(async () => [ + { address: 'yTtgx2GriUKCECox9NWe9eutk7hFU5Hb8j', derivationPath: "m/44'/1'/0'/0/0", index: 0, chain: 0, txid: 'a'.repeat(64), vout: 0, amount: '200000000' } + ]), + changeAddress: jest.fn(async () => 'yRmRnGBF5mjjNPqijsFXqrdV9XpkbdnwcQ'), + reservedOutpoints: jest.fn(() => new Set()), + signPlan: jest.fn(async () => signedTx) + } + + sdk = { + keyPair: { + p2pkhAddress: jest.fn(() => creditOutputAddress) + }, + identities: { + getIdentityByPublicKeyHash: jest.fn(async () => null), + getIdentityByNonUniquePublicKeyHash: jest.fn(async () => null) + }, + stateTransitions: { + broadcast: jest.fn(async () => {}), + waitForStateTransitionResult: jest.fn(async () => {}) + } + } + + deriveIdentityRegistrationKey.mockResolvedValue(PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet')) + deriveIdentityPrivateKey.mockResolvedValue(PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet')) + buildIdentityCreateTransition.mockReturnValue({ + getOwnerId: () => ({ base58: () => identifier }), + hash: () => 'stateTransitionHash', + signByPrivateKey: jest.fn() + }) + waitForAssetLockProofMock.mockResolvedValue({ type: 'instantLock', transaction: 't', instantLock: 'l', outputIndex: 0 } as any) + + handler = new RegisterIdentityHandler( + walletRepository, + identitiesRepository, + assetLockFundingAddressesRepository, + {} as any, + sdk, + coreSDK, + coreAssetLock + ) + }) + + const handle = async (payload: any = {}): Promise => await handler.handle({ + context: 'dash-platform-extension', + id: 'id', + method: 'REGISTER_IDENTITY', + type: 'request', + payload: { password, amountCredits: '100000000000', ...payload } + } as any) + + it('spends the wallet own coins and stores the signed asset lock before broadcasting', async () => { + const result = await handle() + + expect(result.identifier).toBe(identifier) + expect(coreAssetLock.signPlan).toHaveBeenCalledTimes(1) + // The record is opened against the credit output address, with no one-off key. + expect(assetLockFundingAddressesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ + address: creditOutputAddress, + encryptedPrivateKey: null, + assetLockTx: 'signedassetlockhex', + registrationIdentityIndex: 0, + purpose: 'registration' + })) + // Stored first, broadcast second: a crash in between must leave the exact + // transaction to send, not a selection to redo. + expect(assetLockFundingAddressesRepository.create.mock.invocationCallOrder[0]) + .toBeLessThan(coreSDK.broadcastTransaction.mock.invocationCallOrder[0]) + expect(coreSDK.broadcastTransaction).toHaveBeenCalledTimes(1) + }) + + it('resends the stored transaction on a retry instead of selecting coins again', async () => { + await handle() + coreAssetLock.signPlan.mockClear() + coreAssetLock.spendableUtxos.mockClear() + coreSDK.broadcastTransaction.mockClear() + stored[0].used = false + + const result = await handle() + + expect(result.identifier).toBe(identifier) + // Nothing is selected or signed again, and the asset lock is not sent twice. + expect(coreAssetLock.spendableUtxos).not.toHaveBeenCalled() + expect(coreAssetLock.signPlan).not.toHaveBeenCalled() + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() + }) + + it('keeps the identity index pinned to the asset lock across a retry', async () => { + await handle() + stored[0].used = false + deriveIdentityRegistrationKey.mockClear() + + await handle() + + // Every derivation is at the pinned index, so the credit output address - and + // with it the txid - cannot drift. + for (const call of deriveIdentityRegistrationKey.mock.calls) { + expect(call[2]).toBe(0) + } + }) + + it('asks for an amount, and for one that is a whole number of duffs', async () => { + await expect(handle({ amountCredits: undefined })).rejects.toThrow(/amount in credits/) + await expect(handle({ amountCredits: '1500' })).rejects.toThrow(/whole number of duffs/) + expect(coreAssetLock.signPlan).not.toHaveBeenCalled() + }) + + it('leaves the deposit path alone when an address is given', async () => { + stored.push({ address: 'yZPSYxHnNEc6TyZJx6AUrHkAZJcFgp5H9j', encryptedPrivateKey: 'deadbeef', used: false, assetLockTxid: null }) + + await expect(handle({ assetLockFundingAddress: 'yZPSYxHnNEc6TyZJx6AUrHkAZJcFgp5H9j', assetLockFundingTxid: 'a'.repeat(64) })) + .rejects.toThrow(/Failed to decrypt asset lock funding key/) + expect(coreAssetLock.signPlan).not.toHaveBeenCalled() + }) +}) diff --git a/test/api/private/identities/topUpIdentity.spec.ts b/test/api/private/identities/topUpIdentity.spec.ts index ff26f3a4..c53c6afc 100644 --- a/test/api/private/identities/topUpIdentity.spec.ts +++ b/test/api/private/identities/topUpIdentity.spec.ts @@ -61,6 +61,8 @@ describe('TopUpIdentityHandler', () => { let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any + let coreExplorer: any + let coreAssetLock: any let sdk: any let handler: TopUpIdentityHandler let encryptedPrivateKey: string @@ -128,6 +130,8 @@ describe('TopUpIdentityHandler', () => { identitiesRepository.forScope = jest.fn(() => identitiesRepository) assetLockFundingAddressesRepository.forScope = jest.fn(() => assetLockFundingAddressesRepository) + coreAssetLock = {} as any + coreExplorer = { isAddressUsed: jest.fn(async () => false) } as any coreSDK = { // Both SDKs are fixed to a network for the lifetime of their document, and // the handler refuses to run against a scope they cannot serve. @@ -171,7 +175,9 @@ describe('TopUpIdentityHandler', () => { identitiesRepository, assetLockFundingAddressesRepository, sdk, - coreSDK + coreSDK, + coreExplorer, + coreAssetLock ) }) From 1385bda806e45bc80eed605cb9e299ac4a337abe Mon Sep 17 00:00:00 2001 From: LexxXell Date: Wed, 30 Sep 2026 14:08:25 +0400 Subject: [PATCH 2/3] refactor: build the asset lock from a caller-picked own UTXO --- src/constants.ts | 4 + src/content-script/api/PrivateAPI.ts | 10 +- .../api/private/core/listCoreUtxos.ts | 54 +++++ .../private/identities/registerIdentity.ts | 200 +++++++----------- .../api/private/identities/topUpIdentity.ts | 181 ++++++---------- .../services/CoreAssetLockService.ts | 115 ---------- src/content-script/storage/storageSchema.ts | 13 +- src/types/PrivateAPIClient.ts | 7 + src/types/enums/MessagingMethods.ts | 1 + .../payloads/RegisterIdentityPayload.ts | 20 +- .../messages/payloads/TopUpIdentityPayload.ts | 12 +- .../response/ListCoreUtxosResponse.ts | 6 + src/utils/buildAssetLockFromUtxos.ts | 151 ------------- src/utils/coreAddresses.ts | 38 +++- .../identities/registerIdentity.spec.ts | 5 +- ...ec.ts => registerIdentityOwnCoins.spec.ts} | 144 ++++++------- .../private/identities/topUpIdentity.spec.ts | 19 +- 17 files changed, 347 insertions(+), 633 deletions(-) create mode 100644 src/content-script/api/private/core/listCoreUtxos.ts delete mode 100644 src/content-script/services/CoreAssetLockService.ts create mode 100644 src/types/messages/response/ListCoreUtxosResponse.ts delete mode 100644 src/utils/buildAssetLockFromUtxos.ts rename test/api/private/identities/{selfFundedAssetLock.spec.ts => registerIdentityOwnCoins.spec.ts} (55%) diff --git a/src/constants.ts b/src/constants.ts index fe7e80da..00e64fd8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,6 +29,10 @@ export const MIN_TOPUP_FUNDING_DASH = Number(MIN_TOPUP_FUNDING_DUFFS) / 1e8 // Gap limit for scanning DIP-13 top-up funding indexes (m/9'/coin'/5'/2'/N). export const TOPUP_FUNDING_GAP_LIMIT = 20 +// How far along each Core chain an address still counts as the wallet's own, +// when checking that a caller is funding an asset lock from its own coins. +export const CORE_ADDRESS_WINDOW = 100 + // Upper bound for scanning identity indexes when picking the next free one. export const IDENTITY_INDEX_SCAN_LIMIT = 20 diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 8e7d21c4..5833455e 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -40,7 +40,6 @@ import { CreateStateTransitionHandler } from './private/stateTransitions/createS import { CreateIdentityPrivateKeyHandler } from './private/identities/createIdentityPrivateKey' import { AssetLockFundingAddressesRepository } from '../repository/AssetLockFundingAddressesRepository' import { CoreExplorerService } from '../services/CoreExplorerService' -import { CoreAssetLockService } from '../services/CoreAssetLockService' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' import { RegisterIdentityHandler } from './private/identities/registerIdentity' @@ -52,6 +51,7 @@ import { SetSettingsHandler } from './private/settings/setSettings' import { InitAccountXpubsHandler } from './private/wallet/initAccountXpubs' import { GetCoreReceiveAddressHandler } from './private/core/getCoreReceiveAddress' import { ListCoreAddressesHandler } from './private/core/listCoreAddresses' +import { ListCoreUtxosHandler } from './private/core/listCoreUtxos' import { GetCoreBalanceHandler } from './private/core/getCoreBalance' import { GetCoreTransactionsHandler } from './private/core/getCoreTransactions' import { GeneratePlatformAddressesHandler } from './private/wallet/generatePlatformAddresses' @@ -124,7 +124,6 @@ export class PrivateAPI { const assetLockFundingAddressesRepository = new AssetLockFundingAddressesRepository(this.storageAdapter) const walletSettingsRepository = new WalletSettingsRepository(this.storageAdapter) const coreExplorer = new CoreExplorerService() - const coreAssetLock = new CoreAssetLockService(this.sdk, coreExplorer) this.handlers = { [MessagingMethods.GET_STATUS]: new GetStatusHandler(this.storageAdapter, walletRepository), @@ -166,8 +165,7 @@ export class PrivateAPI { assetLockFundingAddressesRepository, this.storageAdapter, this.sdk, - this.coreSDK, - coreAssetLock + this.coreSDK ), [MessagingMethods.TOP_UP_IDENTITY]: new TopUpIdentityHandler( walletRepository, @@ -175,13 +173,13 @@ export class PrivateAPI { assetLockFundingAddressesRepository, this.sdk, this.coreSDK, - coreExplorer, - coreAssetLock + coreExplorer ), [MessagingMethods.GET_SETTINGS]: new GetSettingsHandler(walletSettingsRepository), [MessagingMethods.SET_SETTINGS]: new SetSettingsHandler(walletSettingsRepository), [MessagingMethods.GET_CORE_RECEIVE_ADDRESS]: new GetCoreReceiveAddressHandler(walletRepository, coreExplorer, this.sdk), [MessagingMethods.LIST_CORE_ADDRESSES]: new ListCoreAddressesHandler(walletRepository, coreExplorer, this.sdk), + [MessagingMethods.LIST_CORE_UTXOS]: new ListCoreUtxosHandler(walletRepository, coreExplorer), [MessagingMethods.GET_CORE_BALANCE]: new GetCoreBalanceHandler(walletRepository, coreExplorer), [MessagingMethods.GET_CORE_TRANSACTIONS]: new GetCoreTransactionsHandler(walletRepository, coreExplorer), [MessagingMethods.GENERATE_PLATFORM_ADDRESSES]: new GeneratePlatformAddressesHandler(walletRepository, this.sdk), diff --git a/src/content-script/api/private/core/listCoreUtxos.ts b/src/content-script/api/private/core/listCoreUtxos.ts new file mode 100644 index 00000000..fcd17fb8 --- /dev/null +++ b/src/content-script/api/private/core/listCoreUtxos.ts @@ -0,0 +1,54 @@ +import { APIHandler } from '../../APIHandler' +import { WalletRepository } from '../../../repository/WalletRepository' +import { CoreExplorerService } from '../../../services/CoreExplorerService' +import { EmptyPayload } from '../../../../types/messages/payloads/EmptyPayload' +import { ListCoreUtxosResponse } from '../../../../types/messages/response/ListCoreUtxosResponse' + +// The account's spendable Core (L1) outputs, read by xpub so the list covers +// every address the account derives. It is what a caller picks from when funding +// an asset lock with the wallet's own coins: an output named here can be passed +// to REGISTER_IDENTITY or TOP_UP_IDENTITY as its address and txid. Needs no +// password - the xpub is cached. +export class ListCoreUtxosHandler implements APIHandler { + walletRepository: WalletRepository + coreExplorer: CoreExplorerService + + constructor (walletRepository: WalletRepository, coreExplorer: CoreExplorerService) { + this.walletRepository = walletRepository + this.coreExplorer = coreExplorer + } + + async handle (): Promise { + const wallet = await this.walletRepository.getCurrent() + + if (wallet == null) { + throw new Error('No wallet is chosen') + } + + const xpub = await this.walletRepository.getCoreAccountXpub(0) + + if (xpub == null) { + throw new Error('Core xpub is not initialized. Call INIT_ACCOUNT_XPUBS with the wallet password after unlocking') + } + + const utxos = await this.coreExplorer.getXpubUtxos(xpub, wallet.network) + + // Amounts cross the messaging boundary as strings; bigint does not serialize. + return { + utxos: utxos.map(utxo => ({ + address: utxo.address, + txid: utxo.txid, + vout: utxo.vout, + amountDuffs: utxo.amount.toString() + })) + } + } + + validatePayload (payload: EmptyPayload): string | null { + if ('account' in payload) { + return 'Account is not supported' + } + + return null + } +} diff --git a/src/content-script/api/private/identities/registerIdentity.ts b/src/content-script/api/private/identities/registerIdentity.ts index 53dadf36..c9c78843 100644 --- a/src/content-script/api/private/identities/registerIdentity.ts +++ b/src/content-script/api/private/identities/registerIdentity.ts @@ -1,4 +1,4 @@ -import { DashCoreSDK, Transaction } from 'dash-core-sdk' +import { DashCoreSDK } from 'dash-core-sdk' import { PrivateKeyWASM } from 'dash-platform-sdk/types' import { DashPlatformSDK } from 'dash-platform-sdk' import { PrivateKey, decrypt } from 'eciesjs' @@ -13,8 +13,7 @@ import { RegisterIdentityPayload } from '../../../../types/messages/payloads/Reg import { RegisterIdentityResponse } from '../../../../types/messages/response/RegisterIdentityResponse' import { IdentityType } from '../../../../types/enums/IdentityType' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' -import { CoreAssetLockService } from '../../../services/CoreAssetLockService' -import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' +import { deriveCoreAccountXpub, deriveCoreAddressKey } from '../../../../utils/coreAddresses' import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' import { IDENTITY_KEY_DEFINITIONS, buildIdentityCreateTransition } from '../../../../utils/identityRegistration' @@ -33,7 +32,6 @@ import { TXID_HEX_LENGTH, IDENTITY_INDEX_SCAN_LIMIT, REGISTRATION_CONFIRM_TIMEOU export class RegisterIdentityHandler implements APIHandler { walletRepository: WalletRepository - coreAssetLock: CoreAssetLockService identitiesRepository: IdentitiesRepository assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository storageAdapter: StorageAdapter @@ -46,10 +44,8 @@ export class RegisterIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, storageAdapter: StorageAdapter, sdk: DashPlatformSDK, - coreSDK: DashCoreSDK, - coreAssetLock: CoreAssetLockService + coreSDK: DashCoreSDK ) { - this.coreAssetLock = coreAssetLock this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository this.assetLockFundingAddressesRepository = assetLockFundingAddressesRepository @@ -72,48 +68,31 @@ export class RegisterIdentityHandler implements APIHandler { throw new Error('Identity registration is only supported for seedphrase wallets') } - // ── 2. Load the asset lock record ─────────────────────────────────────── - // Either a deposit the user paid to a one-off address, or - when no address is - // given - the wallet's own coins. In that mode the record is keyed by the - // credit output address and may not exist yet; an unfinished one is picked up - // so a retry continues the same asset lock. - const selfFunded = payload.assetLockFundingAddress == null - let assetLockFundingAddressEntry = selfFunded - ? await this.unfinishedSelfFundedEntry() - : await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress as string) - - if (!selfFunded && assetLockFundingAddressEntry == null) { - throw new Error( - `Asset lock funding address ${String(payload.assetLockFundingAddress)} not found. ` + - 'It may belong to a different wallet or network.' - ) - } + // ── 2. Load the asset lock funding address entry ──────────────────────── + // A one-off deposit address has a record with its key. One of the wallet's own + // addresses has none until this registration opens one, and its key is derived + // from the seed instead. + const depositEntry = await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) + const selfFunded = depositEntry == null + // An own-coins record is keyed by the credit output address, not by the + // address that paid, so the same address can fund another registration later. + // An unfinished one is picked up here, which is what pins the index on a retry. + const assetLockFundingAddressEntry = selfFunded + ? (await this.assetLockFundingAddressesRepository.findAllUnused('registration')) + .find(entry => entry.encryptedPrivateKey == null) ?? null + : depositEntry if (assetLockFundingAddressEntry?.used === true) { - throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} has already been used for registration`) + throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used for registration`) } // ── 3. Decrypt the one-time funding key ───────────────────────────────── // The funding key signs the asset lock tx inputs only. Credit output // ownership and Platform ST signing are handled by identityRegistrationKey // derived in step 5 (DIP-0013). - // Own coins are signed by BIP44 keys derived from the seed, so there is no - // one-off key to decrypt in that mode. - let assetLockFundingPrivateKey: PrivateKeyWASM | null = null - - if (assetLockFundingAddressEntry?.encryptedPrivateKey != null) { - const passwordHash = hash.sha256().update(payload.password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) - - let assetLockFundingKeyBytes: Uint8Array - try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) - } catch { - throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') - } - - assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) - } + const assetLockFundingPrivateKey = selfFunded + ? await this.ownAddressKey(wallet, payload) + : this.depositKey(assetLockFundingAddressEntry as AssetLockFundingAddressSchema, wallet, payload.password) // ── 4. Determine the identity index ───────────────────────────────────── // The credit output address — and therefore the asset lock txid — is derived @@ -124,13 +103,12 @@ export class RegisterIdentityHandler implements APIHandler { // rebuild a different tx than the one already committed on L1. let identityIndex: number - if (assetLockFundingAddressEntry?.registrationIdentityIndex != null) { - // Recovery: reuse the index pinned for this asset lock. With own coins it is - // pinned when the record is opened, before anything is broadcast. - identityIndex = assetLockFundingAddressEntry.registrationIdentityIndex - } else if (assetLockFundingAddressEntry?.assetLockTxid == null) { + if (assetLockFundingAddressEntry?.assetLockTxid == null) { // Fresh registration: scan for the next free index on-chain. identityIndex = await this.scanFreeIdentityIndex(wallet, payload.password) + } else if (assetLockFundingAddressEntry?.registrationIdentityIndex != null) { + // Recovery: reuse the index pinned when the asset lock was broadcast. + identityIndex = assetLockFundingAddressEntry.registrationIdentityIndex } else { // Legacy recovery: the asset lock was broadcast before the index was // pinned. Recover it by finding the index whose rebuilt asset lock matches @@ -138,7 +116,7 @@ export class RegisterIdentityHandler implements APIHandler { identityIndex = await this.recoverIdentityIndexFromTxid( wallet, payload, - assetLockFundingPrivateKey as PrivateKeyWASM, + assetLockFundingPrivateKey, assetLockFundingAddressEntry.assetLockTxid ) } @@ -150,29 +128,16 @@ export class RegisterIdentityHandler implements APIHandler { const creditOutputAddress = this.creditOutputAddress(identityRegistrationKey, wallet.network) // ── 6. Build asset lock transaction ───────────────────────────────────── - // A deposit is spent by its one-time key and rebuilds byte for byte on retry. - // Own coins are selected once, signed, and the signed transaction is stored - // before it is broadcast: the wallet's UTXO set moves, so a retry must send - // that transaction rather than select again. - let assetLockTx: Transaction - - if (selfFunded) { - assetLockTx = assetLockFundingAddressEntry?.assetLockTx != null - ? Transaction.fromHex(assetLockFundingAddressEntry.assetLockTx) - : await this.openSelfFundedAssetLock(wallet, payload, creditOutputAddress, identityIndex) - - assetLockFundingAddressEntry = await this.assetLockFundingAddressesRepository.getByAddress(creditOutputAddress) - } else { - assetLockTx = (await buildAssetLockFromFundingTx( - this.coreSDK, - payload.assetLockFundingTxid as string, - payload.assetLockFundingAddress as string, - (assetLockFundingPrivateKey as PrivateKeyWASM).WIF(), - creditOutputAddress - )).assetLockTx - } + // Inputs are signed by the one-time funding key. Credit output goes to + // creditOutputAddress (registration key). Build is deterministic on retry. + const { assetLockTx } = await buildAssetLockFromFundingTx( + this.coreSDK, + payload.assetLockFundingTxid, + payload.assetLockFundingAddress, + assetLockFundingPrivateKey.WIF(), + creditOutputAddress + ) - const fundingAddress = selfFunded ? creditOutputAddress : payload.assetLockFundingAddress as string const assetLockTxid = assetLockTx.hash() if ( @@ -180,17 +145,33 @@ export class RegisterIdentityHandler implements APIHandler { assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid ) { throw new Error( - `Asset lock funding address ${fundingAddress} is already broadcasted ` + + `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` ) } + // What the record is keyed by: the deposit address, or the credit output + // address when the wallet paid with its own coins. + const recordAddress = selfFunded ? creditOutputAddress : payload.assetLockFundingAddress + + if (assetLockFundingAddressEntry == null) { + // Opened before the transaction can reach the network, so the index this + // asset lock funded is pinned even if everything after this fails. + await this.assetLockFundingAddressesRepository.create({ + address: recordAddress, + encryptedPrivateKey: null, + used: false, + registrationIdentityIndex: identityIndex, + purpose: 'registration' + }) + } + // ── 7. Broadcast the asset lock transaction (skip if already broadcast) ─ // The instant lock subscription is opened in both fresh and recovery modes // because waitForAssetLockProof needs it to receive instant lock events // for txs that are not yet chain-locked. const instantLockSub = this.coreSDK.subscribeToTransactions( - [fundingAddress], + [payload.assetLockFundingAddress], [txidToFilterBytes(assetLockTxid)] ) @@ -199,7 +180,7 @@ export class RegisterIdentityHandler implements APIHandler { // Persist the broadcasted txid AND the identity index before any further // work, so a retry after a crash rebuilds the exact same asset lock instead // of re-scanning to a different index. - await this.assetLockFundingAddressesRepository.markAsBroadcasted(fundingAddress, assetLockTxid, identityIndex) + await this.assetLockFundingAddressesRepository.markAsBroadcasted(recordAddress, assetLockTxid, identityIndex) } // ── 8. Wait for instant lock or chain lock (whichever comes first) ────── @@ -292,7 +273,7 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 15. Mark funding address as used and switch identity ──────────────── - await this.assetLockFundingAddressesRepository.markAsUsed(fundingAddress) + await this.assetLockFundingAddressesRepository.markAsUsed(recordAddress) await this.walletRepository.switchIdentity(identifier) return { @@ -310,62 +291,33 @@ export class RegisterIdentityHandler implements APIHandler { // Address that owns an asset lock credit output for a given registration key // (P2PKH of the DIP-0013 registration key at m/9'/coin'/5'/1'/identityIndex). - // An asset lock this wallet already signed with its own coins and has not - // finished. Such a record has no one-off key, which is what tells it apart from - // a deposit address still waiting for money. - private async unfinishedSelfFundedEntry (): Promise { - const entries = await this.assetLockFundingAddressesRepository.findAllUnused('registration') - - return entries.find(entry => entry.encryptedPrivateKey == null) ?? null - } - - // Selects the wallet's own coins, signs the asset lock and stores it together - // with the identity index - before anything reaches the network. That order is - // what makes a retry safe: the record already names the transaction to send and - // the index whose credit address it pays to. - private async openSelfFundedAssetLock ( - wallet: Wallet, - payload: RegisterIdentityPayload, - creditOutputAddress: string, - identityIndex: number - ): Promise { - if (payload.amountCredits == null) { - throw new Error('Funding an asset lock from your own coins needs an amount in credits') + // The one-off key a deposit address keeps in its record. + private depositKey (entry: AssetLockFundingAddressSchema, wallet: Wallet, password: string): PrivateKeyWASM { + if (entry.encryptedPrivateKey == null) { + throw new Error(`Asset lock funding address ${entry.address} has no one-off key`) } - const amountCredits = BigInt(payload.amountCredits) + const passwordHash = hash.sha256().update(password).digest('hex') + const secretKey = PrivateKey.fromHex(passwordHash) - if (amountCredits % 1000n !== 0n) { - throw new Error('Amount must be a whole number of duffs (1000 credits)') + let assetLockFundingKeyBytes: Uint8Array + try { + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(entry.encryptedPrivateKey)) + } catch { + throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') } - const xpub = await this.coreAssetLock.accountXpub(this.walletRepository, wallet, payload.password) - const [utxos, changeAddress, entries] = await Promise.all([ - this.coreAssetLock.spendableUtxos(xpub, wallet), - this.coreAssetLock.changeAddress(xpub, wallet), - this.assetLockFundingAddressesRepository.getAll() - ]) - - const plan = selectAssetLockUtxos( - utxos, - amountCredits / 1000n, - creditOutputAddress, - changeAddress, - this.coreAssetLock.reservedOutpoints(entries) - ) - - const assetLockTx = await this.coreAssetLock.signPlan(plan, wallet, payload.password) + return PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + } - await this.assetLockFundingAddressesRepository.create({ - address: creditOutputAddress, - encryptedPrivateKey: null, - used: false, - assetLockTx: assetLockTx.hex(), - registrationIdentityIndex: identityIndex, - purpose: 'registration' - }) + // The key of one of the wallet's own addresses, derived from the seed. Refuses + // an address the wallet does not derive, so a caller cannot ask it to sign for + // coins that are not its own. + private async ownAddressKey (wallet: Wallet, payload: RegisterIdentityPayload): Promise { + const xpub = await this.walletRepository.getCoreAccountXpub(0) ?? + await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) - return assetLockTx + return await deriveCoreAddressKey(wallet, payload.password, xpub, payload.assetLockFundingAddress, this.sdk) } private creditOutputAddress (identityRegistrationKey: PrivateKeyWASM, network: Wallet['network']): string { @@ -411,8 +363,8 @@ export class RegisterIdentityHandler implements APIHandler { const { assetLockTx } = await buildAssetLockFromFundingTx( this.coreSDK, - payload.assetLockFundingTxid as string, - payload.assetLockFundingAddress as string, + payload.assetLockFundingTxid, + payload.assetLockFundingAddress, assetLockFundingPrivateKey.WIF(), creditOutputAddress ) diff --git a/src/content-script/api/private/identities/topUpIdentity.ts b/src/content-script/api/private/identities/topUpIdentity.ts index 6768acee..19b9ee04 100644 --- a/src/content-script/api/private/identities/topUpIdentity.ts +++ b/src/content-script/api/private/identities/topUpIdentity.ts @@ -1,4 +1,4 @@ -import { DashCoreSDK, Transaction } from 'dash-core-sdk' +import { DashCoreSDK } from 'dash-core-sdk' import { KeyType, Network, PrivateKeyWASM } from 'dash-platform-sdk/types' import { DashPlatformSDK } from 'dash-platform-sdk' import { PrivateKey, decrypt } from 'eciesjs' @@ -11,10 +11,10 @@ import { AssetLockFundingAddressesRepository } from '../../../repository/AssetLo import { TopUpIdentityPayload } from '../../../../types/messages/payloads/TopUpIdentityPayload' import { TopUpIdentityResponse } from '../../../../types/messages/response/TopUpIdentityResponse' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' -import { CoreAssetLockService } from '../../../services/CoreAssetLockService' -import { CoreExplorerService } from '../../../services/CoreExplorerService' -import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' +import { deriveCoreAccountXpub, deriveCoreAddressKey } from '../../../../utils/coreAddresses' +import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' import { Wallet } from '../../../../types/Wallet' +import { CoreExplorerService } from '../../../services/CoreExplorerService' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' import { deriveTopUpKeyFromHdKey, deriveWalletHdKey, hexToBytes } from '../../../../utils' import { txidToFilterBytes } from '../../../../utils/txidToFilterBytes' @@ -30,7 +30,6 @@ export class TopUpIdentityHandler implements APIHandler { sdk: DashPlatformSDK coreSDK: DashCoreSDK coreExplorer: CoreExplorerService - coreAssetLock: CoreAssetLockService constructor ( walletRepository: WalletRepository, @@ -38,16 +37,14 @@ export class TopUpIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, sdk: DashPlatformSDK, coreSDK: DashCoreSDK, - coreExplorer: CoreExplorerService, - coreAssetLock: CoreAssetLockService + coreExplorer: CoreExplorerService ) { - this.coreExplorer = coreExplorer - this.coreAssetLock = coreAssetLock this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository this.assetLockFundingAddressesRepository = assetLockFundingAddressesRepository this.sdk = sdk this.coreSDK = coreSDK + this.coreExplorer = coreExplorer } async handle (event: EventData): Promise { @@ -79,21 +76,19 @@ export class TopUpIdentityHandler implements APIHandler { throw new Error(`Identity ${payload.identityId} does not belong to wallet ${scope.walletId} on ${scope.network}`) } - // With no address given the wallet pays with its own coins: the record is keyed - // by the credit output address of the DIP-13 top-up key, and an unfinished one - // for this identity is picked up so a retry continues the same asset lock. - const selfFunded = payload.assetLockFundingAddress == null - let assetLockFundingAddressEntry = selfFunded + // A deposit address carries its own record. One of the wallet's own addresses + // does not, and then the top-up is funded from that address's coins: the + // record is keyed by the DIP-13 credit address instead, and an unfinished one + // is picked up on a retry. + const depositEntry = await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) + const selfFunded = depositEntry == null + const assetLockFundingAddressEntry = selfFunded ? (await assetLockFundingAddressesRepository.findAllUnused('topUp', payload.identityId)) .find(entry => entry.encryptedPrivateKey == null) ?? null - : await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress as string) - - if (!selfFunded && assetLockFundingAddressEntry == null) { - throw new Error(`Asset lock funding address ${String(payload.assetLockFundingAddress)} not found`) - } + : depositEntry if (assetLockFundingAddressEntry?.used === true) { - throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} has already been used`) + throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used`) } // An address reserved for another identity is refused rather than spent @@ -104,71 +99,66 @@ export class TopUpIdentityHandler implements APIHandler { assetLockFundingAddressEntry.identityId !== payload.identityId ) { throw new Error( - `Asset lock funding address ${assetLockFundingAddressEntry.address} is reserved ` + + `Asset lock funding address ${payload.assetLockFundingAddress} is reserved ` + `for identity ${assetLockFundingAddressEntry.identityId}` ) } - // A deposit keeps its one-off key in the record; own coins derive the DIP-13 - // top-up key (m/9'/coin'/5'/2'/index) from the seed instead. Either way this is - // the key that owns the credit output and signs the top-up transition. + // `assetLockFundingPrivateKey` owns the credit output and signs the top-up + // below - a DIP-13 top-up key either way. A deposit address IS that key's + // address, so its stored key serves both roles. Own coins are ordinary BIP44 + // outputs: `inputPrivateKey` signs them, while the credits still land on a + // DIP-13 key so another wallet restoring this seed can find them. let assetLockFundingPrivateKey: PrivateKeyWASM + let inputPrivateKey: PrivateKeyWASM let topUpIndex = assetLockFundingAddressEntry?.index - if (assetLockFundingAddressEntry?.encryptedPrivateKey != null) { + if (selfFunded) { + const xpub = await walletRepository.getCoreAccountXpub(0) ?? + await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) + // The caller's address is checked against the account first, so an address + // that is neither a deposit nor the wallet's own is refused before any + // scanning or derivation happens. + inputPrivateKey = await deriveCoreAddressKey(wallet, payload.password, xpub, payload.assetLockFundingAddress, this.sdk) + + const walletHdKey = deriveWalletHdKey(wallet, payload.password, this.sdk) + + topUpIndex = topUpIndex ?? await this.freeTopUpIndex(walletHdKey, wallet, assetLockFundingAddressesRepository) + assetLockFundingPrivateKey = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, topUpIndex, this.sdk) + } else { const passwordHash = hash.sha256().update(payload.password).digest('hex') const secretKey = PrivateKey.fromHex(passwordHash) let assetLockFundingKeyBytes: Uint8Array try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes((assetLockFundingAddressEntry as AssetLockFundingAddressSchema).encryptedPrivateKey as string)) } catch { throw new Error('Failed to decrypt asset lock funding key - wrong password or corrupted entry') } assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) - } else { - const walletHdKey = deriveWalletHdKey(wallet, payload.password, this.sdk) - - topUpIndex = topUpIndex ?? await this.freeTopUpIndex(walletHdKey, wallet, assetLockFundingAddressesRepository) - assetLockFundingPrivateKey = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, topUpIndex, this.sdk) + inputPrivateKey = assetLockFundingPrivateKey } + // Where the credits land, and what the record is keyed by: the deposit + // address, or the DIP-13 credit address when the wallet paid with own coins. const creditOutputAddress = selfFunded ? this.sdk.keyPair.p2pkhAddress(assetLockFundingPrivateKey.getPublicKey().bytes(), wallet.network as Network) - : payload.assetLockFundingAddress as string + : payload.assetLockFundingAddress // Build asset lock transaction. The build is deterministic so the same // inputs produce the same txid on retry. For a top-up the funding key both // funds the asset lock and owns the credit output (it signs the top-up // state transition below), so the credit output goes back to the funding // address — unlike registration, where a separate derived key owns it. - let assetLockTx: Transaction - let lockedAmount: bigint - - if (selfFunded) { - assetLockTx = assetLockFundingAddressEntry?.assetLockTx != null - ? Transaction.fromHex(assetLockFundingAddressEntry.assetLockTx) - : await this.openSelfFundedAssetLock(wallet, payload, creditOutputAddress, topUpIndex as number, assetLockFundingAddressesRepository) - - assetLockFundingAddressEntry = await assetLockFundingAddressesRepository.getByAddress(creditOutputAddress) - // The asset lock's own output carries the locked amount, so a resumed - // operation reads it off the stored transaction instead of the payload. - lockedAmount = assetLockTx.outputs[0].satoshis - } else { - const built = await buildAssetLockFromFundingTx( - this.coreSDK, - payload.assetLockFundingTxid as string, - payload.assetLockFundingAddress as string, - assetLockFundingPrivateKey.WIF(), - payload.assetLockFundingAddress as string - ) - - assetLockTx = built.assetLockTx - lockedAmount = built.lockedAmount - } + const { assetLockTx, lockedAmount } = await buildAssetLockFromFundingTx( + this.coreSDK, + payload.assetLockFundingTxid, + payload.assetLockFundingAddress, + inputPrivateKey.WIF(), + creditOutputAddress + ) - const fundingAddress = creditOutputAddress const assetLockTxid = assetLockTx.hash() if ( @@ -176,7 +166,7 @@ export class TopUpIdentityHandler implements APIHandler { assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid ) { throw new Error( - `Asset lock funding address ${fundingAddress} is already broadcasted ` + + `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` ) } @@ -185,15 +175,28 @@ export class TopUpIdentityHandler implements APIHandler { // because waitForAssetLockProof needs it to receive instant lock events // for txs that are not yet chain-locked. const instantLockSub = this.coreSDK.subscribeToTransactions( - [fundingAddress], + [payload.assetLockFundingAddress], [txidToFilterBytes(assetLockTxid)] ) + if (assetLockFundingAddressEntry == null) { + // Pins the DIP-13 index before the transaction can reach the network, so a + // retry derives the same credit key and rebuilds the same asset lock. + await assetLockFundingAddressesRepository.create({ + address: creditOutputAddress, + encryptedPrivateKey: null, + used: false, + index: topUpIndex, + purpose: 'topUp', + identityId: payload.identityId + }) + } + if (assetLockFundingAddressEntry?.assetLockTxid == null) { await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) // Persist the broadcasted txid before any further work so a crash leaves // a recoverable record of the L1-committed asset lock. - await assetLockFundingAddressesRepository.markAsBroadcasted(fundingAddress, assetLockTxid) + await assetLockFundingAddressesRepository.markAsBroadcasted(creditOutputAddress, assetLockTxid) } const assetLockProof = await waitForAssetLockProof( @@ -222,7 +225,7 @@ export class TopUpIdentityHandler implements APIHandler { } } - await assetLockFundingAddressesRepository.markAsUsed(fundingAddress) + await assetLockFundingAddressesRepository.markAsUsed(creditOutputAddress) return { identityId: payload.identityId, @@ -232,10 +235,10 @@ export class TopUpIdentityHandler implements APIHandler { } // First DIP-13 top-up index whose address has never appeared on L1 and is not - // claimed by a local record - the same gap scan the deposit flow uses to hand out - // an address, so the two never land on the same index. + // claimed by a local record - the same gap scan that hands out a deposit + // address, so a deposit and an own-coins top-up never land on the same index. private async freeTopUpIndex ( - walletHdKey: Awaited>, + walletHdKey: ReturnType, wallet: Wallet, assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository ): Promise { @@ -255,56 +258,6 @@ export class TopUpIdentityHandler implements APIHandler { throw new Error(`No unused top-up funding index found within ${TOPUP_FUNDING_GAP_LIMIT} indexes`) } - // Selects the wallet's own coins, signs the asset lock and stores it with the - // top-up index before anything reaches the network, so a retry sends that same - // transaction instead of selecting again. - private async openSelfFundedAssetLock ( - wallet: Wallet, - payload: TopUpIdentityPayload, - creditOutputAddress: string, - topUpIndex: number, - assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository - ): Promise { - if (payload.amountCredits == null) { - throw new Error('Funding an asset lock from your own coins needs an amount in credits') - } - - const amountCredits = BigInt(payload.amountCredits) - - if (amountCredits % 1000n !== 0n) { - throw new Error('Amount must be a whole number of duffs (1000 credits)') - } - - const xpub = await this.coreAssetLock.accountXpub(this.walletRepository, wallet, payload.password) - const [utxos, changeAddress, entries] = await Promise.all([ - this.coreAssetLock.spendableUtxos(xpub, wallet), - this.coreAssetLock.changeAddress(xpub, wallet), - assetLockFundingAddressesRepository.getAll() - ]) - - const plan = selectAssetLockUtxos( - utxos, - amountCredits / 1000n, - creditOutputAddress, - changeAddress, - this.coreAssetLock.reservedOutpoints(entries) - ) - - const assetLockTx = await this.coreAssetLock.signPlan(plan, wallet, payload.password) - - await assetLockFundingAddressesRepository.create({ - address: creditOutputAddress, - encryptedPrivateKey: null, - used: false, - assetLockTx: assetLockTx.hex(), - index: topUpIndex, - purpose: 'topUp', - identityId: payload.identityId - }) - - return assetLockTx - } - // The pair this operation runs against: taken from the payload when the caller // names it, otherwise snapshotted from the current selection. Both SDKs are // fixed to a network for the lifetime of the document that built them — diff --git a/src/content-script/services/CoreAssetLockService.ts b/src/content-script/services/CoreAssetLockService.ts deleted file mode 100644 index f9917aff..00000000 --- a/src/content-script/services/CoreAssetLockService.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { DashPlatformSDK } from 'dash-platform-sdk' -import { PrivateKey, Transaction } from 'dash-core-sdk' -import { WalletRepository } from '../repository/WalletRepository' -import { CoreExplorerService } from './CoreExplorerService' -import { Wallet } from '../../types/Wallet' -import { CoreUtxo } from '../../types/CoreUtxo' -import { CoreAddressChain } from '../../types/enums/CoreAddressChain' -import { AssetLockFundingAddressSchema } from '../storage/storageSchema' -import { deriveWalletHdKey } from '../../utils' -import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' -import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' - -// Primitives for funding an asset lock with the wallet's own Core coins instead of -// a deposit to a one-off address: the spendable outputs, where change goes, which -// coins another unfinished asset lock already claimed, and the signing. The -// handlers decide when each one runs. -export class CoreAssetLockService { - sdk: DashPlatformSDK - explorer: CoreExplorerService - - constructor (sdk: DashPlatformSDK, explorer: CoreExplorerService) { - this.sdk = sdk - this.explorer = explorer - } - - // The account xpub, cached on first use so later reads need no password. An xpub - // that does not match this seed belongs to another wallet and is never - // overwritten silently. - async accountXpub (walletRepository: WalletRepository, wallet: Wallet, password: string): Promise { - const stored = await walletRepository.getCoreAccountXpub(0) - const derived = await deriveCoreAccountXpub(wallet, password, 0, this.sdk) - - if (stored != null && stored !== derived) { - throw new Error('Core xpub does not belong to this seed') - } - - if (stored == null) { - await walletRepository.setCoreAccountXpub(0, derived) - } - - return derived - } - - // Everything the account can spend right now, read by xpub so it covers addresses - // this install never derived. Each output is matched back to the address entry it - // belongs to, which carries the derivation path the signing needs; an output on an - // address beyond the window is left out rather than guessed at. - async spendableUtxos (xpub: string, wallet: Wallet): Promise { - const utxos = await this.explorer.getXpubUtxos(xpub, wallet.network) - const derived = new Map(this.accountAddresses(xpub, wallet).map(entry => [entry.address, entry])) - - return utxos - .filter(utxo => derived.has(utxo.address)) - .map(utxo => ({ - ...(derived.get(utxo.address) as CoreAddressEntry), - txid: utxo.txid, - vout: utxo.vout, - amount: utxo.amount.toString() - })) - } - - // Both chains of the account, as far out as the wallet hands addresses. - private accountAddresses (xpub: string, wallet: Wallet): CoreAddressEntry[] { - return [CoreAddressChain.receiving, CoreAddressChain.change].flatMap(chain => - deriveCoreAddressesFromXpub(this.sdk, xpub, wallet.network, 0, chain, CORE_ADDRESS_WINDOW)) - } - - // Change goes to the next change address the explorer has not seen used, so two - // asset locks in a row do not pay themselves to the same one. - async changeAddress (xpub: string, wallet: Wallet): Promise { - const { nextUnused } = await this.explorer.getXpubSummary(xpub, wallet.network) - - return deriveCoreAddressesFromXpub(this.sdk, xpub, wallet.network, 0, CoreAddressChain.change, 1, nextUnused.change)[0].address - } - - // Outpoints an unfinished asset lock has already signed. Its transaction may - // still reach the network, so a new one must not spend the same coins. - reservedOutpoints (entries: AssetLockFundingAddressSchema[]): Set { - const reserved = new Set() - - for (const entry of entries) { - if (entry.used || entry.assetLockTx == null) { - continue - } - - for (const input of Transaction.fromHex(entry.assetLockTx).inputs) { - reserved.add(`${input.getTxIdHex()}:${input.vOut}`) - } - } - - return reserved - } - - // Signs the planned asset lock with one BIP44 key per input, derived from the - // seed at the path the explorer reported for that address. - async signPlan (plan: CoreAssetLockPlan, wallet: Wallet, password: string): Promise { - const root = deriveWalletHdKey(wallet, password, this.sdk) - const keys: PrivateKey[] = [] - - for (const input of plan.inputs) { - const child = await this.sdk.keyPair.derivePath(root, input.derivationPath) - - if (child.privateKey == null) { - throw new Error(`Could not derive the key for Core input address ${input.address}`) - } - - keys.push(PrivateKey.fromBytes(child.privateKey, wallet.network)) - } - - return buildAssetLockFromUtxos(plan, keys) - } -} - -// How far along each chain an address is still considered the wallet's own. -const CORE_ADDRESS_WINDOW = 100 diff --git a/src/content-script/storage/storageSchema.ts b/src/content-script/storage/storageSchema.ts index 0b85fc68..ac7c1bcc 100644 --- a/src/content-script/storage/storageSchema.ts +++ b/src/content-script/storage/storageSchema.ts @@ -77,20 +77,13 @@ export interface AppConnectsStorageSchema { export type AssetLockFundingPurpose = 'registration' | 'topUp' export interface AssetLockFundingAddressSchema { - // The address this entry is keyed by: the one-off deposit address for a funded - // asset lock, or the credit output address when the wallet pays with its own - // coins (nothing is deposited, so there is no deposit address to name). address: string - // Null when the wallet pays with its own coins: the inputs are signed by BIP44 - // keys derived from the seed, so there is no one-off key to keep. + // Null when the asset lock is funded from the wallet's own coins: the inputs + // are ordinary BIP44 outputs, signed with a key derived from the seed, so there + // is no one-off key to keep. encryptedPrivateKey: string | null used: boolean assetLockTxid?: string | null - // The signed asset lock, hex, stored before it is broadcast when the wallet pays - // with its own coins. A deposit has a single input and rebuilds byte for byte, but - // a selection over the wallet's own UTXOs would change as soon as one of them is - // spent elsewhere, and a retry must send the same transaction, not a second one. - assetLockTx?: string // DIP-13 derivation index for top-up funding keys (m/9'/coin'/5'/2'/index). // Absent for registration entries, which use a one-time random funding key. index?: number diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index 14e856f2..0f7c3f5c 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -4,6 +4,7 @@ import { EventData } from './EventData' import { NetworkType } from './NetworkType' import { GetCoreAddressesResponse } from './messages/response/GetCoreAddressesResponse' import { GetCoreBalanceResponse } from './messages/response/GetCoreBalanceResponse' +import { ListCoreUtxosResponse } from './messages/response/ListCoreUtxosResponse' import { GetCoreTransactionsPayload } from './messages/payloads/GetCoreTransactionsPayload' import { GetCoreTransactionsResponse } from './messages/response/GetCoreTransactionsResponse' import { InitAccountXpubsPayload } from './messages/payloads/InitAccountXpubsPayload' @@ -454,6 +455,12 @@ export class PrivateAPIClient { return response.addresses } + // The account's spendable Core outputs, for picking which coins fund an asset + // lock. No password: the xpub is cached. + async listCoreUtxos (): Promise { + return await this._rpcCall(MessagingMethods.LIST_CORE_UTXOS, {}) + } + async getCoreBalance (): Promise { const payload: EmptyPayload = {} diff --git a/src/types/enums/MessagingMethods.ts b/src/types/enums/MessagingMethods.ts index 27214f1d..cc0eead7 100644 --- a/src/types/enums/MessagingMethods.ts +++ b/src/types/enums/MessagingMethods.ts @@ -39,6 +39,7 @@ export enum MessagingMethods { SET_SETTINGS = 'SET_SETTINGS', GET_CORE_RECEIVE_ADDRESS = 'GET_CORE_RECEIVE_ADDRESS', LIST_CORE_ADDRESSES = 'LIST_CORE_ADDRESSES', + LIST_CORE_UTXOS = 'LIST_CORE_UTXOS', GET_CORE_BALANCE = 'GET_CORE_BALANCE', GET_CORE_TRANSACTIONS = 'GET_CORE_TRANSACTIONS', GENERATE_PLATFORM_ADDRESSES = 'GENERATE_PLATFORM_ADDRESSES', diff --git a/src/types/messages/payloads/RegisterIdentityPayload.ts b/src/types/messages/payloads/RegisterIdentityPayload.ts index 639ea057..763b6d14 100644 --- a/src/types/messages/payloads/RegisterIdentityPayload.ts +++ b/src/types/messages/payloads/RegisterIdentityPayload.ts @@ -1,17 +1,13 @@ export interface RegisterIdentityPayload { /** - * The asset lock funding P2PKH address that received the payment. Omit it, with - * the txid, to fund the asset lock from the wallet's own Core coins instead. + * The P2PKH address whose outputs fund the asset lock: either a one-off + * deposit address this extension handed out, or one of the wallet's own Core + * addresses, in which case its key comes from the seed and the caller picks + * which coins to spend (LIST_CORE_UTXOS shows them). */ - assetLockFundingAddress?: string - /** Txid of the asset lock funding transaction that paid to the address */ - assetLockFundingTxid?: string - /** Extension password used to decrypt the asset lock funding private key */ + assetLockFundingAddress: string + /** Txid of the transaction that paid to that address */ + assetLockFundingTxid: string + /** Extension password: decrypts the one-off key, or the seed */ password: string - /** - * How many credits to lock, when the wallet pays with its own coins. A deposit - * locks whatever it received, so this belongs to that mode only. Credits, as a - * string; must be a whole number of duffs (1000 credits). - */ - amountCredits?: string } diff --git a/src/types/messages/payloads/TopUpIdentityPayload.ts b/src/types/messages/payloads/TopUpIdentityPayload.ts index 89b8cbd8..d7f1ac80 100644 --- a/src/types/messages/payloads/TopUpIdentityPayload.ts +++ b/src/types/messages/payloads/TopUpIdentityPayload.ts @@ -2,14 +2,12 @@ import { NetworkType } from '../../NetworkType' export interface TopUpIdentityPayload { identityId: string - // Omit both, with an amount instead, to fund the asset lock from the wallet's - // own Core coins rather than a deposit to a one-off address. - assetLockFundingAddress?: string - assetLockFundingTxid?: string + // Either a one-off deposit address this extension handed out, or one of the + // wallet's own Core addresses - then its key comes from the seed and the caller + // picks which coins to spend (LIST_CORE_UTXOS shows them). + assetLockFundingAddress: string + assetLockFundingTxid: string password: string - // Credits to lock when the wallet pays with its own coins; a deposit locks - // whatever it received. Must be a whole number of duffs (1000 credits). - amountCredits?: string // The (network, wallet) the top-up is bound to. Optional: callers that omit // them get the extension's current selection, snapshotted once when the // handler starts. A long-lived caller (the top-up tab, which stays open across diff --git a/src/types/messages/response/ListCoreUtxosResponse.ts b/src/types/messages/response/ListCoreUtxosResponse.ts new file mode 100644 index 00000000..8b867143 --- /dev/null +++ b/src/types/messages/response/ListCoreUtxosResponse.ts @@ -0,0 +1,6 @@ +export interface ListCoreUtxosResponse { + // One entry per spendable output of the account. `address` and `txid` are what + // an asset lock is funded with; every output of that transaction paying to that + // address is spent together, which is why the amount is shown per output. + utxos: Array<{ address: string, txid: string, vout: number, amountDuffs: string }> +} diff --git a/src/utils/buildAssetLockFromUtxos.ts b/src/utils/buildAssetLockFromUtxos.ts deleted file mode 100644 index 3f683356..00000000 --- a/src/utils/buildAssetLockFromUtxos.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { ExtraPayload, Input, Output, PrivateKey, Script, Transaction, TransactionType } from 'dash-core-sdk' -import { CoreUtxo } from '../types/CoreUtxo' - -// Conservative P2PKH input size: outpoint, compact length, 108-byte script, sequence. -const SIGNED_INPUT_SIZE = 149 -// Below this an output costs more to spend than it holds, so change is dropped -// into the fee instead of being created. -const DUST_DUFFS = 546n -export const CORE_ASSET_LOCK_FEE_PER_BYTE = 1n - -// What an asset lock will spend and pay, decided before anything is signed. The -// credit output is the DIP-13 address that owns the locked credits; the change -// goes back to the wallet's own change chain. -export interface CoreAssetLockPlan { - inputs: CoreUtxo[] - amountDuffs: string - feeDuffs: string - changeDuffs: string - changeAddress: string - creditOutputAddress: string -} - -const transaction = (plan: CoreAssetLockPlan): Transaction => { - const amount = BigInt(plan.amountDuffs) - const outputs = [new Output(amount, Script.fromASM('OP_RETURN OP_0'))] - - if (BigInt(plan.changeDuffs) > 0n) { - outputs.push(Output.createP2PKH(BigInt(plan.changeDuffs), plan.changeAddress)) - } - - return new Transaction( - plan.inputs.map(input => new Input(input.txid, input.vout, Output.createP2PKH(0n, input.address).script, 0xffffffff)), - outputs, - 0, - undefined, - TransactionType.TRANSACTION_ASSET_LOCK, - new ExtraPayload.AssetLockTx(1, 1, [Output.createP2PKH(amount, plan.creditOutputAddress)]) - ) -} - -const estimateFee = (plan: CoreAssetLockPlan, rate: bigint): bigint => { - const tx = transaction(plan) - // Replace each unsigned script with the largest signed P2PKH input. The - // serialized tx already accounts for compact counts and the special payload. - const size = tx.bytes().length + tx.inputs.reduce((sum, input) => sum + SIGNED_INPUT_SIZE - input.bytes().length, 0) - - return BigInt(size) * rate -} - -// Picks the wallet's own UTXOs for an asset lock of `amountDuffs`, largest first, -// recomputing the fee as each input is added. `reserved` holds outpoints an -// unfinished asset lock already committed to, so two of them never spend the same -// coin. Throws rather than silently funding less than asked. -export const selectAssetLockUtxos = ( - utxos: CoreUtxo[], - amountDuffs: bigint, - creditOutputAddress: string, - changeAddress: string, - reserved: Set = new Set(), - feePerByte: bigint = CORE_ASSET_LOCK_FEE_PER_BYTE -): CoreAssetLockPlan => { - if (amountDuffs <= 0n || amountDuffs > 21000000n * 100000000n) { - throw new Error('Invalid asset lock amount') - } - if (feePerByte <= 0n) { - throw new Error('Invalid Core fee rate') - } - - const seen = new Set() - const available = utxos.filter(input => { - const outpoint = `${input.txid}:${input.vout}` - const malformed = !/^[a-fA-F0-9]{64}$/.test(input.txid) || - !Number.isSafeInteger(input.vout) || input.vout < 0 || input.vout > 0xffffffff || - !/^\d+$/.test(input.amount) || BigInt(input.amount) <= 0n - - if (malformed) { - throw new Error('Invalid Core UTXO') - } - if (seen.has(outpoint)) { - throw new Error('Duplicate Core outpoint') - } - - seen.add(outpoint) - - return !reserved.has(outpoint) - }).sort((left, right) => { - if (BigInt(left.amount) === BigInt(right.amount)) { - return `${left.txid}:${left.vout}`.localeCompare(`${right.txid}:${right.vout}`) - } - - return BigInt(left.amount) > BigInt(right.amount) ? -1 : 1 - }) - - const plan: CoreAssetLockPlan = { - inputs: [], - amountDuffs: amountDuffs.toString(), - feeDuffs: '0', - changeDuffs: '0', - creditOutputAddress, - changeAddress - } - - let total = 0n - - for (const input of available) { - plan.inputs.push(input) - total += BigInt(input.amount) - plan.changeDuffs = '0' - - if (total < amountDuffs + estimateFee(plan, feePerByte)) { - continue - } - - // Enough with no change; see whether the leftover is worth an output at all. - plan.changeDuffs = DUST_DUFFS.toString() - const change = total - amountDuffs - estimateFee(plan, feePerByte) - - plan.changeDuffs = change >= DUST_DUFFS ? change.toString() : '0' - plan.feeDuffs = (total - amountDuffs - BigInt(plan.changeDuffs)).toString() - - return plan - } - - throw new Error('Insufficient spendable Core balance for this amount plus fee') -} - -// Signs the planned asset lock, one key per input in the same order. Each key is -// checked against the address it spends, so a derivation that drifted cannot sign -// someone else's coin, and the amounts are checked to balance before signing. -export const buildAssetLockFromUtxos = (plan: CoreAssetLockPlan, keys: PrivateKey[]): Transaction => { - if (keys.length !== plan.inputs.length || keys.length === 0) { - throw new Error('One Core key is required per input') - } - - plan.inputs.forEach((input, index) => { - if (keys[index].getAddress() !== input.address) { - throw new Error('Core input does not belong to its derived key') - } - }) - - const total = plan.inputs.reduce((sum, input) => sum + BigInt(input.amount), 0n) - - if (total !== BigInt(plan.amountDuffs) + BigInt(plan.changeDuffs) + BigInt(plan.feeDuffs)) { - throw new Error('Core transaction amounts do not balance') - } - - const tx = transaction(plan) - tx.sign(keys) - - return tx -} diff --git a/src/utils/coreAddresses.ts b/src/utils/coreAddresses.ts index c078ed7d..1f9187f4 100644 --- a/src/utils/coreAddresses.ts +++ b/src/utils/coreAddresses.ts @@ -1,11 +1,11 @@ import { HDKey } from '@scure/bip32' import { DashPlatformSDK } from 'dash-platform-sdk' -import { Network } from 'dash-platform-sdk/types' +import { Network, PrivateKeyWASM } from 'dash-platform-sdk/types' import { Wallet } from '../types/Wallet' import { NetworkType } from '../types/NetworkType' import { CoreAddressChain } from '../types/enums/CoreAddressChain' -import { CORE_BIP32_VERSIONS } from '../constants' -import { decryptMnemonic } from './index' +import { CORE_ADDRESS_WINDOW, CORE_BIP32_VERSIONS } from '../constants' +import { decryptMnemonic, deriveWalletHdKey } from './index' // BIP44 derivation for Core (L1) addresses: m/44'/coin'/account'/chain/index. // @@ -80,3 +80,35 @@ export const deriveCoreAddressesFromXpub = ( return entries } + +// The private key of one of the wallet's own Core addresses, found by walking both +// chains of the account. Funding an asset lock with the wallet's own coins needs +// it: the inputs are ordinary BIP44 outputs, not a one-off deposit, so the key +// comes from the seed rather than from storage. An address outside the window is +// not one this wallet hands out, and is refused rather than signed for. +export const deriveCoreAddressKey = async ( + wallet: Wallet, + password: string, + xpub: string, + address: string, + sdk: DashPlatformSDK, + account: number = 0 +): Promise => { + const chains = [CoreAddressChain.receiving, CoreAddressChain.change] + const entry = chains + .flatMap(chain => deriveCoreAddressesFromXpub(sdk, xpub, wallet.network, account, chain, CORE_ADDRESS_WINDOW)) + .find(candidate => candidate.address === address) + + if (entry == null) { + throw new Error(`Core address ${address} is not one of this wallet's own addresses`) + } + + const root = deriveWalletHdKey(wallet, password, sdk) + const derived = await sdk.keyPair.derivePath(root, entry.derivationPath) + + if (derived.privateKey == null) { + throw new Error(`Could not derive the key for Core address ${address}`) + } + + return PrivateKeyWASM.fromBytes(derived.privateKey, wallet.network) +} diff --git a/test/api/private/identities/registerIdentity.spec.ts b/test/api/private/identities/registerIdentity.spec.ts index 2cc4a3c3..4294f853 100644 --- a/test/api/private/identities/registerIdentity.spec.ts +++ b/test/api/private/identities/registerIdentity.spec.ts @@ -56,7 +56,6 @@ describe('RegisterIdentityHandler', () => { let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any - let coreAssetLock: any let sdk: any let handler: RegisterIdentityHandler let encryptedPrivateKey: string @@ -122,7 +121,6 @@ describe('RegisterIdentityHandler', () => { }) } - coreAssetLock = {} as any coreSDK = { subscribeToTransactions: jest.fn(() => { order.push('subscribe') @@ -177,8 +175,7 @@ describe('RegisterIdentityHandler', () => { assetLockFundingAddressesRepository, {} as any, sdk, - coreSDK, - coreAssetLock + coreSDK ) }) diff --git a/test/api/private/identities/selfFundedAssetLock.spec.ts b/test/api/private/identities/registerIdentityOwnCoins.spec.ts similarity index 55% rename from test/api/private/identities/selfFundedAssetLock.spec.ts rename to test/api/private/identities/registerIdentityOwnCoins.spec.ts index 1820c4d1..f513a5fa 100644 --- a/test/api/private/identities/selfFundedAssetLock.spec.ts +++ b/test/api/private/identities/registerIdentityOwnCoins.spec.ts @@ -1,13 +1,26 @@ import { PrivateKeyWASM } from 'dash-platform-sdk/types' -import { Transaction } from 'dash-core-sdk' import { RegisterIdentityHandler } from '../../../../src/content-script/api/private/identities/registerIdentity' +import { buildAssetLockFromFundingTx } from '../../../../src/utils/buildAssetLockFromFundingTx' import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' +import { deriveCoreAddressKey } from '../../../../src/utils/coreAddresses' import { WalletType } from '../../../../src/types' +jest.mock('../../../../src/utils/buildAssetLockFromFundingTx', () => ({ + buildAssetLockFromFundingTx: jest.fn() +})) + jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ waitForAssetLockProof: jest.fn() })) +jest.mock('../../../../src/utils/coreAddresses', () => { + const actual = jest.requireActual('../../../../src/utils/coreAddresses') + return { + ...actual, + deriveCoreAddressKey: jest.fn() + } +}) + jest.mock('../../../../src/utils/identityRegistration', () => ({ IDENTITY_KEY_DEFINITIONS: [{ id: 0 }], buildIdentityCreateTransition: jest.fn() @@ -22,44 +35,37 @@ jest.mock('../../../../src/utils', () => { } }) +const buildAssetLockFromFundingTxMock = buildAssetLockFromFundingTx as jest.MockedFunction const waitForAssetLockProofMock = waitForAssetLockProof as jest.MockedFunction +const deriveCoreAddressKeyMock = deriveCoreAddressKey as jest.MockedFunction const { buildIdentityCreateTransition } = jest.requireMock('../../../../src/utils/identityRegistration') const { deriveIdentityRegistrationKey, deriveIdentityPrivateKey } = jest.requireMock('../../../../src/utils') -// An asset lock funded from the wallet's own Core coins: no deposit, no one-off -// key. The pipeline is the one the deposit flow uses; only the transaction's -// inputs and the bookkeeping around them differ, so that is what this covers. -describe('RegisterIdentityHandler funded from the wallet own coins', () => { +// Funding an asset lock with one of the wallet's own Core outputs: the caller +// names the address and the transaction that paid it, exactly as it does for a +// deposit, and the key comes from the seed instead of a stored one-off entry. +// Everything after the key is develop's pipeline, so what is covered here is the +// key, the record, and that nothing else changed shape. +describe('RegisterIdentityHandler funded from an own Core output', () => { const identifier = 'HT3pUBM1Uv2mKgdPEN1gxa7A4PdsvNY89aJbdSKQb5wR' - // Real testnet addresses: the fee estimate builds an actual transaction. - const creditOutputAddress = 'yjLG5HeifV72L78cr6EW4sEC9AATZnmLXA' - const password = 'test' + const ownAddress = 'yTtgx2GriUKCECox9NWe9eutk7hFU5Hb8j' + const creditAddress = 'yjLG5HeifV72L78cr6EW4sEC9AATZnmLXA' + const fundingTxid = 'a'.repeat(64) const assetLockTxid = 'b'.repeat(64) + const password = 'test' let stored: any[] let walletRepository: any let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any - let coreAssetLock: any let sdk: any - let signedTx: any let handler: RegisterIdentityHandler beforeEach(() => { jest.clearAllMocks() stored = [] - signedTx = { - hash: () => assetLockTxid, - hex: () => 'signedassetlockhex', - bytes: () => Uint8Array.from([1, 2, 3]) - } - - // The stored bytes are this stub, so parsing them back is stubbed as well; - // what matters here is that the stored transaction is the one that gets sent. - jest.spyOn(Transaction, 'fromHex').mockReturnValue(signedTx) - walletRepository = { getCurrent: jest.fn(async () => ({ walletId: 'wallet1', @@ -71,8 +77,7 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { currentIdentity: null })), switchIdentity: jest.fn(async () => {}), - getCoreAccountXpub: jest.fn(async () => 'xpub'), - setCoreAccountXpub: jest.fn(async () => {}) + getCoreAccountXpub: jest.fn(async () => 'xpub') } identitiesRepository = { @@ -85,7 +90,6 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { assetLockFundingAddressesRepository = { findAllUnused: jest.fn(async () => stored.filter(entry => entry.used !== true)), getByAddress: jest.fn(async (address: string) => stored.find(entry => entry.address === address) ?? null), - getAll: jest.fn(async () => stored), create: jest.fn(async (entry: any) => { stored.push(entry) return entry @@ -105,20 +109,8 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { subscribeToTransactions: jest.fn(() => ({ async * [Symbol.asyncIterator] () {} })) } - coreAssetLock = { - accountXpub: jest.fn(async () => 'xpub'), - spendableUtxos: jest.fn(async () => [ - { address: 'yTtgx2GriUKCECox9NWe9eutk7hFU5Hb8j', derivationPath: "m/44'/1'/0'/0/0", index: 0, chain: 0, txid: 'a'.repeat(64), vout: 0, amount: '200000000' } - ]), - changeAddress: jest.fn(async () => 'yRmRnGBF5mjjNPqijsFXqrdV9XpkbdnwcQ'), - reservedOutpoints: jest.fn(() => new Set()), - signPlan: jest.fn(async () => signedTx) - } - sdk = { - keyPair: { - p2pkhAddress: jest.fn(() => creditOutputAddress) - }, + keyPair: { p2pkhAddress: jest.fn(() => creditAddress) }, identities: { getIdentityByPublicKeyHash: jest.fn(async () => null), getIdentityByNonUniquePublicKeyHash: jest.fn(async () => null) @@ -129,8 +121,15 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { } } - deriveIdentityRegistrationKey.mockResolvedValue(PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet')) - deriveIdentityPrivateKey.mockResolvedValue(PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet')) + const key = PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet') + + deriveCoreAddressKeyMock.mockResolvedValue(key) + deriveIdentityRegistrationKey.mockResolvedValue(key) + deriveIdentityPrivateKey.mockResolvedValue(key) + buildAssetLockFromFundingTxMock.mockResolvedValue({ + assetLockTx: { hash: () => assetLockTxid, bytes: () => Uint8Array.from([1]) }, + lockedAmount: 100000000n + } as any) buildIdentityCreateTransition.mockReturnValue({ getOwnerId: () => ({ base58: () => identifier }), hash: () => 'stateTransitionHash', @@ -144,8 +143,7 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { assetLockFundingAddressesRepository, {} as any, sdk, - coreSDK, - coreAssetLock + coreSDK ) }) @@ -154,70 +152,58 @@ describe('RegisterIdentityHandler funded from the wallet own coins', () => { id: 'id', method: 'REGISTER_IDENTITY', type: 'request', - payload: { password, amountCredits: '100000000000', ...payload } + payload: { password, assetLockFundingAddress: ownAddress, assetLockFundingTxid: fundingTxid, ...payload } } as any) - it('spends the wallet own coins and stores the signed asset lock before broadcasting', async () => { + it('signs the asset lock with the key of the wallet own address', async () => { const result = await handle() expect(result.identifier).toBe(identifier) - expect(coreAssetLock.signPlan).toHaveBeenCalledTimes(1) - // The record is opened against the credit output address, with no one-off key. + expect(deriveCoreAddressKeyMock).toHaveBeenCalledWith(expect.anything(), password, 'xpub', ownAddress, sdk) + // The transaction is built exactly as for a deposit: same builder, same + // arguments, only the key is derived rather than decrypted. + expect(buildAssetLockFromFundingTxMock).toHaveBeenCalledWith( + coreSDK, fundingTxid, ownAddress, expect.any(String), creditAddress + ) + }) + + it('opens the record and pins the identity index before broadcasting', async () => { + await handle() + + // Keyed by the credit output address, so the paying address stays reusable. expect(assetLockFundingAddressesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ - address: creditOutputAddress, + address: creditAddress, encryptedPrivateKey: null, - assetLockTx: 'signedassetlockhex', registrationIdentityIndex: 0, purpose: 'registration' })) - // Stored first, broadcast second: a crash in between must leave the exact - // transaction to send, not a selection to redo. expect(assetLockFundingAddressesRepository.create.mock.invocationCallOrder[0]) .toBeLessThan(coreSDK.broadcastTransaction.mock.invocationCallOrder[0]) - expect(coreSDK.broadcastTransaction).toHaveBeenCalledTimes(1) + expect(assetLockFundingAddressesRepository.markAsBroadcasted) + .toHaveBeenCalledWith(creditAddress, assetLockTxid, 0) }) - it('resends the stored transaction on a retry instead of selecting coins again', async () => { + it('rebuilds the same asset lock on a retry and does not send it twice', async () => { await handle() - coreAssetLock.signPlan.mockClear() - coreAssetLock.spendableUtxos.mockClear() - coreSDK.broadcastTransaction.mockClear() stored[0].used = false + coreSDK.broadcastTransaction.mockClear() + deriveIdentityRegistrationKey.mockClear() const result = await handle() expect(result.identifier).toBe(identifier) - // Nothing is selected or signed again, and the asset lock is not sent twice. - expect(coreAssetLock.spendableUtxos).not.toHaveBeenCalled() - expect(coreAssetLock.signPlan).not.toHaveBeenCalled() - expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() - }) - - it('keeps the identity index pinned to the asset lock across a retry', async () => { - await handle() - stored[0].used = false - deriveIdentityRegistrationKey.mockClear() - - await handle() - - // Every derivation is at the pinned index, so the credit output address - and - // with it the txid - cannot drift. + // The pinned index is reused, so the rebuilt transaction is the same one, and + // the asset lock already on L1 is not broadcast again. for (const call of deriveIdentityRegistrationKey.mock.calls) { expect(call[2]).toBe(0) } + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() }) - it('asks for an amount, and for one that is a whole number of duffs', async () => { - await expect(handle({ amountCredits: undefined })).rejects.toThrow(/amount in credits/) - await expect(handle({ amountCredits: '1500' })).rejects.toThrow(/whole number of duffs/) - expect(coreAssetLock.signPlan).not.toHaveBeenCalled() - }) - - it('leaves the deposit path alone when an address is given', async () => { - stored.push({ address: 'yZPSYxHnNEc6TyZJx6AUrHkAZJcFgp5H9j', encryptedPrivateKey: 'deadbeef', used: false, assetLockTxid: null }) + it('refuses an address the wallet does not own', async () => { + deriveCoreAddressKeyMock.mockRejectedValue(new Error("Core address yfoo is not one of this wallet's own addresses")) - await expect(handle({ assetLockFundingAddress: 'yZPSYxHnNEc6TyZJx6AUrHkAZJcFgp5H9j', assetLockFundingTxid: 'a'.repeat(64) })) - .rejects.toThrow(/Failed to decrypt asset lock funding key/) - expect(coreAssetLock.signPlan).not.toHaveBeenCalled() + await expect(handle({ assetLockFundingAddress: 'yfoo' })).rejects.toThrow(/not one of this wallet/) + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() }) }) diff --git a/test/api/private/identities/topUpIdentity.spec.ts b/test/api/private/identities/topUpIdentity.spec.ts index c53c6afc..e966cf4f 100644 --- a/test/api/private/identities/topUpIdentity.spec.ts +++ b/test/api/private/identities/topUpIdentity.spec.ts @@ -62,7 +62,6 @@ describe('TopUpIdentityHandler', () => { let assetLockFundingAddressesRepository: any let coreSDK: any let coreExplorer: any - let coreAssetLock: any let sdk: any let handler: TopUpIdentityHandler let encryptedPrivateKey: string @@ -88,6 +87,8 @@ describe('TopUpIdentityHandler', () => { } walletRepository = { + // A real testnet account xpub: the own-address check expands it for real. + getCoreAccountXpub: jest.fn(async () => 'tpubDDfiaQD79RwqzH87Zb9rCKY3ETVrasx1aWXs8KrCTEpcgScuFig9UYFYCkH4D94xvG5BenQrFAz7PrqNEjHeDx3tiJC6dDk1JtvPBev3NjS'), getCurrent: jest.fn(async () => ({ walletId: 'wallet1', type: WalletType.keystore, @@ -110,6 +111,7 @@ describe('TopUpIdentityHandler', () => { } assetLockFundingAddressesRepository = { + findAllUnused: jest.fn(async () => []), getByAddress: jest.fn(async () => ({ address: assetLockFundingAddress, encryptedPrivateKey, @@ -130,8 +132,7 @@ describe('TopUpIdentityHandler', () => { identitiesRepository.forScope = jest.fn(() => identitiesRepository) assetLockFundingAddressesRepository.forScope = jest.fn(() => assetLockFundingAddressesRepository) - coreAssetLock = {} as any - coreExplorer = { isAddressUsed: jest.fn(async () => false) } as any + coreExplorer = { isAddressUsed: jest.fn(async () => false) } coreSDK = { // Both SDKs are fixed to a network for the lifetime of their document, and // the handler refuses to run against a scope they cannot serve. @@ -147,6 +148,9 @@ describe('TopUpIdentityHandler', () => { sdk = { getNetwork: jest.fn(() => 'testnet'), + // Every derived address differs from the funding one, so an address with no + // record reads as "not the wallet's own". + keyPair: { p2pkhAddress: jest.fn(() => 'yOtherAddressOfThisWallet') }, identities: { createStateTransition: jest.fn(() => stateTransition) }, @@ -176,8 +180,7 @@ describe('TopUpIdentityHandler', () => { assetLockFundingAddressesRepository, sdk, coreSDK, - coreExplorer, - coreAssetLock + coreExplorer ) }) @@ -252,10 +255,10 @@ describe('TopUpIdentityHandler', () => { expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() }) - test('rejects missing funding address', async () => { - assetLockFundingAddressesRepository.getByAddress.mockResolvedValueOnce(null) + test('rejects an address that is neither a deposit nor one of the wallet own', async () => { + assetLockFundingAddressesRepository.getByAddress.mockResolvedValue(null) - await expect(handle()).rejects.toThrow(`Asset lock funding address ${assetLockFundingAddress} not found`) + await expect(handle()).rejects.toThrow(/is not one of this wallet's own addresses/) expect(assetLockFundingAddressesRepository.markAsBroadcasted).not.toHaveBeenCalled() expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() From e1f278feb2c41a8f95fd789328b76267dd9c82ec Mon Sep 17 00:00:00 2001 From: LexxXell Date: Wed, 30 Sep 2026 17:09:30 +0400 Subject: [PATCH 3/3] refactor: keep the own-coins asset lock to develop's pipeline and read retries from L1 --- src/constants.ts | 4 - src/content-script/api/PrivateAPI.ts | 3 +- .../api/private/core/listCoreUtxos.ts | 13 +- .../private/identities/registerIdentity.ts | 143 +++++------ .../api/private/identities/topUpIdentity.ts | 161 ++++++------ .../wallet/fundPlatformAddressFromCore.ts | 4 - .../AssetLockFundingAddressesRepository.ts | 6 - .../services/CoreExplorerService.ts | 84 ++++--- src/content-script/storage/storageSchema.ts | 5 +- src/types/CoreUtxo.ts | 10 - src/types/PrivateAPIClient.ts | 12 +- .../payloads/RegisterIdentityPayload.ts | 11 +- .../messages/payloads/TopUpIdentityPayload.ts | 3 - .../response/ListCoreUtxosResponse.ts | 4 +- src/utils/coreAddresses.ts | 40 +-- .../identities/assetLockFromOwnCoins.spec.ts | 233 ++++++++++++++++++ .../identities/registerIdentity.spec.ts | 10 +- .../registerIdentityOwnCoins.spec.ts | 209 ---------------- .../private/identities/topUpIdentity.spec.ts | 32 ++- .../services/CoreExplorerService.spec.ts | 68 +++++ 20 files changed, 579 insertions(+), 476 deletions(-) delete mode 100644 src/types/CoreUtxo.ts create mode 100644 test/api/private/identities/assetLockFromOwnCoins.spec.ts delete mode 100644 test/api/private/identities/registerIdentityOwnCoins.spec.ts diff --git a/src/constants.ts b/src/constants.ts index 00e64fd8..fe7e80da 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -29,10 +29,6 @@ export const MIN_TOPUP_FUNDING_DASH = Number(MIN_TOPUP_FUNDING_DUFFS) / 1e8 // Gap limit for scanning DIP-13 top-up funding indexes (m/9'/coin'/5'/2'/N). export const TOPUP_FUNDING_GAP_LIMIT = 20 -// How far along each Core chain an address still counts as the wallet's own, -// when checking that a caller is funding an asset lock from its own coins. -export const CORE_ADDRESS_WINDOW = 100 - // Upper bound for scanning identity indexes when picking the next free one. export const IDENTITY_INDEX_SCAN_LIMIT = 20 diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 5833455e..bad236af 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -165,7 +165,8 @@ export class PrivateAPI { assetLockFundingAddressesRepository, this.storageAdapter, this.sdk, - this.coreSDK + this.coreSDK, + coreExplorer ), [MessagingMethods.TOP_UP_IDENTITY]: new TopUpIdentityHandler( walletRepository, diff --git a/src/content-script/api/private/core/listCoreUtxos.ts b/src/content-script/api/private/core/listCoreUtxos.ts index fcd17fb8..d104047e 100644 --- a/src/content-script/api/private/core/listCoreUtxos.ts +++ b/src/content-script/api/private/core/listCoreUtxos.ts @@ -1,14 +1,12 @@ import { APIHandler } from '../../APIHandler' import { WalletRepository } from '../../../repository/WalletRepository' import { CoreExplorerService } from '../../../services/CoreExplorerService' +import { NetworkType } from '../../../../types/PlatformExplorer' import { EmptyPayload } from '../../../../types/messages/payloads/EmptyPayload' import { ListCoreUtxosResponse } from '../../../../types/messages/response/ListCoreUtxosResponse' -// The account's spendable Core (L1) outputs, read by xpub so the list covers -// every address the account derives. It is what a caller picks from when funding -// an asset lock with the wallet's own coins: an output named here can be passed -// to REGISTER_IDENTITY or TOP_UP_IDENTITY as its address and txid. Needs no -// password - the xpub is cached. +// The account's spendable Core outputs, read by xpub. An output listed here is +// what the caller names when funding an asset lock from the wallet's own coins. export class ListCoreUtxosHandler implements APIHandler { walletRepository: WalletRepository coreExplorer: CoreExplorerService @@ -28,12 +26,11 @@ export class ListCoreUtxosHandler implements APIHandler { const xpub = await this.walletRepository.getCoreAccountXpub(0) if (xpub == null) { - throw new Error('Core xpub is not initialized. Call INIT_ACCOUNT_XPUBS with the wallet password after unlocking') + return { utxos: [] } } - const utxos = await this.coreExplorer.getXpubUtxos(xpub, wallet.network) + const utxos = await this.coreExplorer.getXpubUtxos(xpub, wallet.network as NetworkType) - // Amounts cross the messaging boundary as strings; bigint does not serialize. return { utxos: utxos.map(utxo => ({ address: utxo.address, diff --git a/src/content-script/api/private/identities/registerIdentity.ts b/src/content-script/api/private/identities/registerIdentity.ts index c9c78843..7c9ada0a 100644 --- a/src/content-script/api/private/identities/registerIdentity.ts +++ b/src/content-script/api/private/identities/registerIdentity.ts @@ -13,8 +13,9 @@ import { RegisterIdentityPayload } from '../../../../types/messages/payloads/Reg import { RegisterIdentityResponse } from '../../../../types/messages/response/RegisterIdentityResponse' import { IdentityType } from '../../../../types/enums/IdentityType' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' -import { deriveCoreAccountXpub, deriveCoreAddressKey } from '../../../../utils/coreAddresses' -import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' +import { deriveCoreAccountXpub, deriveCoreAddressPrivateKey } from '../../../../utils/coreAddresses' +import { CoreExplorerService } from '../../../services/CoreExplorerService' +import { NetworkType } from '../../../../types/PlatformExplorer' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' import { IDENTITY_KEY_DEFINITIONS, buildIdentityCreateTransition } from '../../../../utils/identityRegistration' import { @@ -37,6 +38,7 @@ export class RegisterIdentityHandler implements APIHandler { storageAdapter: StorageAdapter sdk: DashPlatformSDK coreSDK: DashCoreSDK + coreExplorer: CoreExplorerService constructor ( walletRepository: WalletRepository, @@ -44,7 +46,8 @@ export class RegisterIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, storageAdapter: StorageAdapter, sdk: DashPlatformSDK, - coreSDK: DashCoreSDK + coreSDK: DashCoreSDK, + coreExplorer: CoreExplorerService ) { this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository @@ -52,6 +55,7 @@ export class RegisterIdentityHandler implements APIHandler { this.storageAdapter = storageAdapter this.sdk = sdk this.coreSDK = coreSDK + this.coreExplorer = coreExplorer } async handle (event: EventData): Promise { @@ -69,18 +73,10 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 2. Load the asset lock funding address entry ──────────────────────── - // A one-off deposit address has a record with its key. One of the wallet's own - // addresses has none until this registration opens one, and its key is derived - // from the seed instead. - const depositEntry = await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) - const selfFunded = depositEntry == null - // An own-coins record is keyed by the credit output address, not by the - // address that paid, so the same address can fund another registration later. - // An unfinished one is picked up here, which is what pins the index on a retry. - const assetLockFundingAddressEntry = selfFunded - ? (await this.assetLockFundingAddressesRepository.findAllUnused('registration')) - .find(entry => entry.encryptedPrivateKey == null) ?? null - : depositEntry + // No record means the address is not a deposit this extension handed out, + // but one of the wallet's own: it is funded from its own coins, and L1 keeps + // the only record of what an earlier attempt committed. + const assetLockFundingAddressEntry = await this.assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) if (assetLockFundingAddressEntry?.used === true) { throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used for registration`) @@ -90,9 +86,29 @@ export class RegisterIdentityHandler implements APIHandler { // The funding key signs the asset lock tx inputs only. Credit output // ownership and Platform ST signing are handled by identityRegistrationKey // derived in step 5 (DIP-0013). - const assetLockFundingPrivateKey = selfFunded - ? await this.ownAddressKey(wallet, payload) - : this.depositKey(assetLockFundingAddressEntry as AssetLockFundingAddressSchema, wallet, payload.password) + let assetLockFundingPrivateKey: PrivateKeyWASM + + if (assetLockFundingAddressEntry == null) { + const xpub = await this.walletRepository.getCoreAccountXpub(0) ?? + await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) + const { nextUnused } = await this.coreExplorer.getXpubSummary(xpub, wallet.network as NetworkType) + + assetLockFundingPrivateKey = await deriveCoreAddressPrivateKey( + wallet, payload.password, xpub, payload.assetLockFundingAddress, nextUnused, this.sdk + ) + } else { + const passwordHash = hash.sha256().update(payload.password).digest('hex') + const secretKey = PrivateKey.fromHex(passwordHash) + + let assetLockFundingKeyBytes: Uint8Array + try { + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) + } catch { + throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') + } + + assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + } // ── 4. Determine the identity index ───────────────────────────────────── // The credit output address — and therefore the asset lock txid — is derived @@ -103,7 +119,13 @@ export class RegisterIdentityHandler implements APIHandler { // rebuild a different tx than the one already committed on L1. let identityIndex: number - if (assetLockFundingAddressEntry?.assetLockTxid == null) { + // Without a record, the funding output itself says whether an earlier + // attempt already committed an asset lock, and which one. + const committedAssetLockTxid = assetLockFundingAddressEntry == null + ? await this.coreExplorer.getOutputSpender(payload.assetLockFundingTxid, payload.assetLockFundingAddress, wallet.network as NetworkType) + : assetLockFundingAddressEntry.assetLockTxid ?? null + + if (committedAssetLockTxid == null) { // Fresh registration: scan for the next free index on-chain. identityIndex = await this.scanFreeIdentityIndex(wallet, payload.password) } else if (assetLockFundingAddressEntry?.registrationIdentityIndex != null) { @@ -117,7 +139,7 @@ export class RegisterIdentityHandler implements APIHandler { wallet, payload, assetLockFundingPrivateKey, - assetLockFundingAddressEntry.assetLockTxid + committedAssetLockTxid ) } @@ -140,32 +162,13 @@ export class RegisterIdentityHandler implements APIHandler { const assetLockTxid = assetLockTx.hash() - if ( - assetLockFundingAddressEntry?.assetLockTxid != null && - assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid - ) { + if (committedAssetLockTxid != null && committedAssetLockTxid !== assetLockTxid) { throw new Error( `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + - `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` + `with a different asset lock txid (${committedAssetLockTxid})` ) } - // What the record is keyed by: the deposit address, or the credit output - // address when the wallet paid with its own coins. - const recordAddress = selfFunded ? creditOutputAddress : payload.assetLockFundingAddress - - if (assetLockFundingAddressEntry == null) { - // Opened before the transaction can reach the network, so the index this - // asset lock funded is pinned even if everything after this fails. - await this.assetLockFundingAddressesRepository.create({ - address: recordAddress, - encryptedPrivateKey: null, - used: false, - registrationIdentityIndex: identityIndex, - purpose: 'registration' - }) - } - // ── 7. Broadcast the asset lock transaction (skip if already broadcast) ─ // The instant lock subscription is opened in both fresh and recovery modes // because waitForAssetLockProof needs it to receive instant lock events @@ -175,12 +178,24 @@ export class RegisterIdentityHandler implements APIHandler { [txidToFilterBytes(assetLockTxid)] ) - if (assetLockFundingAddressEntry?.assetLockTxid == null) { - await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) - // Persist the broadcasted txid AND the identity index before any further - // work, so a retry after a crash rebuilds the exact same asset lock instead - // of re-scanning to a different index. - await this.assetLockFundingAddressesRepository.markAsBroadcasted(recordAddress, assetLockTxid, identityIndex) + if (committedAssetLockTxid == null) { + try { + await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) + } catch (e) { + // The explorer lags the mempool, so an asset lock broadcast moments ago + // still reads as unspent. Rebuilt byte for byte, it is the same + // transaction, and the network rejecting it as known is not a failure. + if (await this.coreSDK.getTransaction(assetLockTxid).catch(() => null) == null) { + throw e + } + } + + if (assetLockFundingAddressEntry != null) { + // Persist the broadcasted txid AND the identity index before any further + // work, so a retry after a crash rebuilds the exact same asset lock instead + // of re-scanning to a different index. + await this.assetLockFundingAddressesRepository.markAsBroadcasted(payload.assetLockFundingAddress, assetLockTxid, identityIndex) + } } // ── 8. Wait for instant lock or chain lock (whichever comes first) ────── @@ -273,7 +288,10 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 15. Mark funding address as used and switch identity ──────────────── - await this.assetLockFundingAddressesRepository.markAsUsed(recordAddress) + if (assetLockFundingAddressEntry != null) { + await this.assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + } + await this.walletRepository.switchIdentity(identifier) return { @@ -291,35 +309,6 @@ export class RegisterIdentityHandler implements APIHandler { // Address that owns an asset lock credit output for a given registration key // (P2PKH of the DIP-0013 registration key at m/9'/coin'/5'/1'/identityIndex). - // The one-off key a deposit address keeps in its record. - private depositKey (entry: AssetLockFundingAddressSchema, wallet: Wallet, password: string): PrivateKeyWASM { - if (entry.encryptedPrivateKey == null) { - throw new Error(`Asset lock funding address ${entry.address} has no one-off key`) - } - - const passwordHash = hash.sha256().update(password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) - - let assetLockFundingKeyBytes: Uint8Array - try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(entry.encryptedPrivateKey)) - } catch { - throw new Error('Failed to decrypt asset lock funding key — wrong password or corrupted entry') - } - - return PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) - } - - // The key of one of the wallet's own addresses, derived from the seed. Refuses - // an address the wallet does not derive, so a caller cannot ask it to sign for - // coins that are not its own. - private async ownAddressKey (wallet: Wallet, payload: RegisterIdentityPayload): Promise { - const xpub = await this.walletRepository.getCoreAccountXpub(0) ?? - await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) - - return await deriveCoreAddressKey(wallet, payload.password, xpub, payload.assetLockFundingAddress, this.sdk) - } - private creditOutputAddress (identityRegistrationKey: PrivateKeyWASM, network: Wallet['network']): string { return this.sdk.keyPair.p2pkhAddress(identityRegistrationKey.getPublicKey().bytes(), network as any) } diff --git a/src/content-script/api/private/identities/topUpIdentity.ts b/src/content-script/api/private/identities/topUpIdentity.ts index 19b9ee04..15d011d5 100644 --- a/src/content-script/api/private/identities/topUpIdentity.ts +++ b/src/content-script/api/private/identities/topUpIdentity.ts @@ -11,10 +11,10 @@ import { AssetLockFundingAddressesRepository } from '../../../repository/AssetLo import { TopUpIdentityPayload } from '../../../../types/messages/payloads/TopUpIdentityPayload' import { TopUpIdentityResponse } from '../../../../types/messages/response/TopUpIdentityResponse' import { buildAssetLockFromFundingTx } from '../../../../utils/buildAssetLockFromFundingTx' -import { deriveCoreAccountXpub, deriveCoreAddressKey } from '../../../../utils/coreAddresses' -import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' -import { Wallet } from '../../../../types/Wallet' +import { deriveCoreAccountXpub, deriveCoreAddressPrivateKey } from '../../../../utils/coreAddresses' import { CoreExplorerService } from '../../../services/CoreExplorerService' +import { NetworkType } from '../../../../types/PlatformExplorer' +import { Wallet } from '../../../../types/Wallet' import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' import { deriveTopUpKeyFromHdKey, deriveWalletHdKey, hexToBytes } from '../../../../utils' import { txidToFilterBytes } from '../../../../utils/txidToFilterBytes' @@ -76,16 +76,9 @@ export class TopUpIdentityHandler implements APIHandler { throw new Error(`Identity ${payload.identityId} does not belong to wallet ${scope.walletId} on ${scope.network}`) } - // A deposit address carries its own record. One of the wallet's own addresses - // does not, and then the top-up is funded from that address's coins: the - // record is keyed by the DIP-13 credit address instead, and an unfinished one - // is picked up on a retry. - const depositEntry = await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) - const selfFunded = depositEntry == null - const assetLockFundingAddressEntry = selfFunded - ? (await assetLockFundingAddressesRepository.findAllUnused('topUp', payload.identityId)) - .find(entry => entry.encryptedPrivateKey == null) ?? null - : depositEntry + // No record means the address is not a deposit this extension handed out, + // but one of the wallet's own: the top-up is funded from its own coins. + const assetLockFundingAddressEntry = await assetLockFundingAddressesRepository.getByAddress(payload.assetLockFundingAddress) if (assetLockFundingAddressEntry?.used === true) { throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used`) @@ -104,53 +97,50 @@ export class TopUpIdentityHandler implements APIHandler { ) } + // Without a record, the funding output itself says whether an earlier + // attempt already committed an asset lock, and which one. + const committedAssetLockTxid = assetLockFundingAddressEntry == null + ? await this.coreExplorer.getOutputSpender(payload.assetLockFundingTxid, payload.assetLockFundingAddress, wallet.network as NetworkType) + : assetLockFundingAddressEntry.assetLockTxid ?? null + // `assetLockFundingPrivateKey` owns the credit output and signs the top-up - // below - a DIP-13 top-up key either way. A deposit address IS that key's - // address, so its stored key serves both roles. Own coins are ordinary BIP44 - // outputs: `inputPrivateKey` signs them, while the credits still land on a - // DIP-13 key so another wallet restoring this seed can find them. + // state transition below; `inputPrivateKey` signs the asset lock inputs. A + // deposit address is both at once. Own coins are ordinary BIP44 outputs, so + // the two part ways: the credits still land on a DIP-13 top-up key. let assetLockFundingPrivateKey: PrivateKeyWASM let inputPrivateKey: PrivateKeyWASM - let topUpIndex = assetLockFundingAddressEntry?.index + let creditOutputAddress: string - if (selfFunded) { + if (assetLockFundingAddressEntry == null) { const xpub = await walletRepository.getCoreAccountXpub(0) ?? await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) - // The caller's address is checked against the account first, so an address - // that is neither a deposit nor the wallet's own is refused before any - // scanning or derivation happens. - inputPrivateKey = await deriveCoreAddressKey(wallet, payload.password, xpub, payload.assetLockFundingAddress, this.sdk) + const { nextUnused } = await this.coreExplorer.getXpubSummary(xpub, wallet.network as NetworkType) - const walletHdKey = deriveWalletHdKey(wallet, payload.password, this.sdk) - - topUpIndex = topUpIndex ?? await this.freeTopUpIndex(walletHdKey, wallet, assetLockFundingAddressesRepository) - assetLockFundingPrivateKey = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, topUpIndex, this.sdk) + inputPrivateKey = await deriveCoreAddressPrivateKey( + wallet, payload.password, xpub, payload.assetLockFundingAddress, nextUnused, this.sdk + ) + assetLockFundingPrivateKey = committedAssetLockTxid == null + ? await this.freeTopUpKey(wallet, payload.password, assetLockFundingAddressesRepository) + : await this.recoverTopUpKeyFromTxid(wallet, payload, inputPrivateKey, committedAssetLockTxid) + creditOutputAddress = this.sdk.keyPair.p2pkhAddress(assetLockFundingPrivateKey.getPublicKey().bytes(), wallet.network as Network) } else { const passwordHash = hash.sha256().update(payload.password).digest('hex') const secretKey = PrivateKey.fromHex(passwordHash) let assetLockFundingKeyBytes: Uint8Array try { - assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes((assetLockFundingAddressEntry as AssetLockFundingAddressSchema).encryptedPrivateKey as string)) + assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) } catch { throw new Error('Failed to decrypt asset lock funding key - wrong password or corrupted entry') } assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) inputPrivateKey = assetLockFundingPrivateKey + creditOutputAddress = payload.assetLockFundingAddress } - // Where the credits land, and what the record is keyed by: the deposit - // address, or the DIP-13 credit address when the wallet paid with own coins. - const creditOutputAddress = selfFunded - ? this.sdk.keyPair.p2pkhAddress(assetLockFundingPrivateKey.getPublicKey().bytes(), wallet.network as Network) - : payload.assetLockFundingAddress - // Build asset lock transaction. The build is deterministic so the same - // inputs produce the same txid on retry. For a top-up the funding key both - // funds the asset lock and owns the credit output (it signs the top-up - // state transition below), so the credit output goes back to the funding - // address — unlike registration, where a separate derived key owns it. + // inputs produce the same txid on retry. const { assetLockTx, lockedAmount } = await buildAssetLockFromFundingTx( this.coreSDK, payload.assetLockFundingTxid, @@ -161,13 +151,10 @@ export class TopUpIdentityHandler implements APIHandler { const assetLockTxid = assetLockTx.hash() - if ( - assetLockFundingAddressEntry?.assetLockTxid != null && - assetLockFundingAddressEntry.assetLockTxid !== assetLockTxid - ) { + if (committedAssetLockTxid != null && committedAssetLockTxid !== assetLockTxid) { throw new Error( `Asset lock funding address ${payload.assetLockFundingAddress} is already broadcasted ` + - `with a different asset lock txid (${assetLockFundingAddressEntry.assetLockTxid})` + `with a different asset lock txid (${committedAssetLockTxid})` ) } @@ -179,24 +166,23 @@ export class TopUpIdentityHandler implements APIHandler { [txidToFilterBytes(assetLockTxid)] ) - if (assetLockFundingAddressEntry == null) { - // Pins the DIP-13 index before the transaction can reach the network, so a - // retry derives the same credit key and rebuilds the same asset lock. - await assetLockFundingAddressesRepository.create({ - address: creditOutputAddress, - encryptedPrivateKey: null, - used: false, - index: topUpIndex, - purpose: 'topUp', - identityId: payload.identityId - }) - } + if (committedAssetLockTxid == null) { + try { + await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) + } catch (e) { + // The explorer lags the mempool, so an asset lock broadcast moments ago + // still reads as unspent. Rebuilt byte for byte, it is the same + // transaction, and the network rejecting it as known is not a failure. + if (await this.coreSDK.getTransaction(assetLockTxid).catch(() => null) == null) { + throw e + } + } - if (assetLockFundingAddressEntry?.assetLockTxid == null) { - await this.coreSDK.broadcastTransaction(assetLockTx.bytes()) - // Persist the broadcasted txid before any further work so a crash leaves - // a recoverable record of the L1-committed asset lock. - await assetLockFundingAddressesRepository.markAsBroadcasted(creditOutputAddress, assetLockTxid) + if (assetLockFundingAddressEntry != null) { + // Persist the broadcasted txid before any further work so a crash leaves + // a recoverable record of the L1-committed asset lock. + await assetLockFundingAddressesRepository.markAsBroadcasted(payload.assetLockFundingAddress, assetLockTxid) + } } const assetLockProof = await waitForAssetLockProof( @@ -225,7 +211,9 @@ export class TopUpIdentityHandler implements APIHandler { } } - await assetLockFundingAddressesRepository.markAsUsed(creditOutputAddress) + if (assetLockFundingAddressEntry != null) { + await assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + } return { identityId: payload.identityId, @@ -234,30 +222,57 @@ export class TopUpIdentityHandler implements APIHandler { } } - // First DIP-13 top-up index whose address has never appeared on L1 and is not - // claimed by a local record - the same gap scan that hands out a deposit - // address, so a deposit and an own-coins top-up never land on the same index. - private async freeTopUpIndex ( - walletHdKey: ReturnType, - wallet: Wallet, - assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository - ): Promise { + // Credit output owner for a top-up paid with the wallet's own coins: the first + // DIP-13 top-up key (m/9'/coin'/5'/2'/N) whose address has never appeared on + // L1 and is not claimed by a local entry - the same rule that hands out a + // deposit address, so the two never land on the same index. + private async freeTopUpKey (wallet: Wallet, password: string, assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository): Promise { + const walletHdKey = deriveWalletHdKey(wallet, password, this.sdk) + for (let index = 0; index < TOPUP_FUNDING_GAP_LIMIT; index++) { const candidate = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, index, this.sdk) - const address = this.sdk.keyPair.p2pkhAddress(candidate.getPublicKey().bytes(), wallet.network as Network) + const candidateAddress = this.sdk.keyPair.p2pkhAddress(candidate.getPublicKey().bytes(), wallet.network as Network) - if (await assetLockFundingAddressesRepository.getByAddress(address) != null) { + if (await assetLockFundingAddressesRepository.getByAddress(candidateAddress) != null) { continue } - if (!await this.coreExplorer.isAddressUsed(address, wallet.network)) { - return index + if (!await this.coreExplorer.isAddressUsed(candidateAddress, wallet.network as NetworkType)) { + return candidate } } throw new Error(`No unused top-up funding index found within ${TOPUP_FUNDING_GAP_LIMIT} indexes`) } + // Recovers the credit output key of an asset lock an earlier attempt already + // committed: the index whose rebuilt transaction is that one. Nothing was + // stored, so the committed txid read from L1 is the only anchor. + private async recoverTopUpKeyFromTxid (wallet: Wallet, payload: TopUpIdentityPayload, inputPrivateKey: PrivateKeyWASM, committedTxid: string): Promise { + const walletHdKey = deriveWalletHdKey(wallet, payload.password, this.sdk) + + for (let index = 0; index < TOPUP_FUNDING_GAP_LIMIT; index++) { + const candidate = await deriveTopUpKeyFromHdKey(walletHdKey, wallet.network, index, this.sdk) + const candidateAddress = this.sdk.keyPair.p2pkhAddress(candidate.getPublicKey().bytes(), wallet.network as Network) + + const { assetLockTx } = await buildAssetLockFromFundingTx( + this.coreSDK, + payload.assetLockFundingTxid, + payload.assetLockFundingAddress, + inputPrivateKey.WIF(), + candidateAddress + ) + + if (assetLockTx.hash() === committedTxid) { + return candidate + } + } + + throw new Error( + `Could not recover the top-up funding index for the committed asset lock ${committedTxid} within ${TOPUP_FUNDING_GAP_LIMIT} indexes` + ) + } + // The pair this operation runs against: taken from the payload when the caller // names it, otherwise snapshotted from the current selection. Both SDKs are // fixed to a network for the lifetime of the document that built them — diff --git a/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts b/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts index 5e45aa5d..dfeda49f 100644 --- a/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts +++ b/src/content-script/api/private/wallet/fundPlatformAddressFromCore.ts @@ -66,10 +66,6 @@ export class FundPlatformAddressFromCoreHandler implements APIHandler { const passwordHash = hash.sha256().update(payload.password).digest('hex') const secretKey = PrivateKey.fromHex(passwordHash) - if (assetLockFundingAddressEntry.encryptedPrivateKey == null) { - throw new Error(`Asset lock funding address ${assetLockFundingAddressEntry.address} is funded from the wallet's own coins and has no one-off key`) - } - let assetLockFundingKeyBytes: Uint8Array try { assetLockFundingKeyBytes = decrypt(secretKey.toHex(), hexToBytes(assetLockFundingAddressEntry.encryptedPrivateKey)) diff --git a/src/content-script/repository/AssetLockFundingAddressesRepository.ts b/src/content-script/repository/AssetLockFundingAddressesRepository.ts index 8296c73b..3a82c7c1 100644 --- a/src/content-script/repository/AssetLockFundingAddressesRepository.ts +++ b/src/content-script/repository/AssetLockFundingAddressesRepository.ts @@ -114,12 +114,6 @@ export class AssetLockFundingAddressesRepository { // Entries with no owner still match: they predate per-identity reservation, and // may already hold a deposit, so the caller reuses and claims them rather than // stranding the money. - async getAll (): Promise { - const storageKey = await this.getStorageKey() - - return Object.values((await this.storageAdapter.get(storageKey) ?? {}) as AssetLockFundingAddressesSchema) - } - async findAllUnused (purpose: AssetLockFundingPurpose = 'registration', identityId?: string): Promise { const storageKey = await this.getStorageKey() const addresses = (await this.storageAdapter.get(storageKey) ?? {}) as AssetLockFundingAddressesSchema diff --git a/src/content-script/services/CoreExplorerService.ts b/src/content-script/services/CoreExplorerService.ts index b590d4e8..0e1cc746 100644 --- a/src/content-script/services/CoreExplorerService.ts +++ b/src/content-script/services/CoreExplorerService.ts @@ -26,7 +26,6 @@ export interface CoreAddressUtxo { amount: bigint } -// An account output, with the address that received it. export interface CoreXpubUtxo extends CoreAddressUtxo { address: string } @@ -64,9 +63,6 @@ export interface CoreExplorerTransactionsPage { nextCursor: string | null } -// Largest page the explorer serves for its /xpub list endpoints. -const XPUB_PAGE_LIMIT = 100 - const getBaseUrl = (network: NetworkType = 'testnet'): string => { return CORE_EXPLORER_URLS[network].api } @@ -220,6 +216,39 @@ export class CoreExplorerService { } } + // Every spendable output of the xpub's addresses, so an asset lock can be + // funded from the wallet's own coins without deriving and asking address by + // address. + async getXpubUtxos (xpub: string, network: NetworkType = 'testnet'): Promise { + const baseUrl = getBaseUrl(network) + const utxos: CoreXpubUtxo[] = [] + + let fetched = 0 + for (let page = 1; ; page++) { + const response = await postJson(`${baseUrl}/xpub/utxo`, { xpub, page, limit: CORE_EXPLORER_MAX_PAGE_LIMIT }) + + if (!response.ok) { + throw new Error(`Core explorer error for xpub utxo: HTTP ${response.status}`) + } + + const data = await response.json() + const rows: any[] = Array.isArray(data?.resultSet) ? data.resultSet : [] + + fetched += rows.length + for (const row of rows) { + const address = toAddress(row.address) + + if (address != null) { + utxos.push({ address, txid: String(row.prevTxHash), vout: toCount(row.vOutIndex), amount: toBigInt(row.amount) }) + } + } + + if (rows.length === 0 || fetched >= toCount(data?.pagination?.total)) { + return utxos + } + } + } + // One page of the transactions touching the xpub's addresses, newest first. // The first page also carries every mempool transaction, ahead of the // confirmed ones. `cursor` is the previous page's `nextCursor`. @@ -249,40 +278,33 @@ export class CoreExplorerService { return info != null && info.txCount > 0 } - // Every confirmed output the account can spend, across both of the xpub's - // chains, so an asset lock can be funded without deriving addresses locally and - // asking about each one. Paged: the explorer caps a page at XPUB_PAGE_LIMIT and - // reports the total, which is how the walk knows it is done. - async getXpubUtxos (xpub: string, network: NetworkType = 'testnet'): Promise { + // Transaction that spent this transaction's output to `address`, or null while + // it is unspent. Recovers the asset lock committed by an earlier attempt: the + // funding transaction is known, the asset lock built from it is not. + async getOutputSpender (txid: string, address: string, network: NetworkType = 'testnet'): Promise { const baseUrl = getBaseUrl(network) - const utxos: CoreXpubUtxo[] = [] + const response = await fetch(`${baseUrl}/transaction/${txid}`) - let fetched = 0 - for (let page = 1; ; page++) { - const response = await fetch(`${baseUrl}/xpub/utxo`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ xpub, page, limit: XPUB_PAGE_LIMIT }) - }) - - if (!response.ok) { - throw new Error(`Core explorer error for xpub utxo: HTTP ${response.status}`) - } + if (response.status === 404) { + return null + } - const data = await response.json() - const rows: any[] = Array.isArray(data?.resultSet) ? data.resultSet : [] + if (!response.ok) { + throw new Error(`Core explorer error for transaction ${txid}: HTTP ${response.status}`) + } - fetched += rows.length - for (const row of rows) { - if (typeof row.address === 'string' && typeof row.prevTxHash === 'string') { - utxos.push({ address: row.address, txid: row.prevTxHash, vout: toCount(row.vOutIndex), amount: toBigInt(row.amount) }) - } - } + const data = await response.json() + const outputs: any[] = Array.isArray(data?.vOut) ? data.vOut : [] - if (rows.length === 0 || fetched >= toCount(data?.pagination?.total)) { - return utxos + for (const output of outputs) { + // Every output paying the address funds the same asset lock, so the first + // spent one names it. + if (toAddress(output.address) === address && typeof output.spentTxId === 'string' && output.spentTxId !== '') { + return output.spentTxId } } + + return null } // Confirmed UTXOs for an address. Empty when the address is unseen or has no diff --git a/src/content-script/storage/storageSchema.ts b/src/content-script/storage/storageSchema.ts index ac7c1bcc..242cc4aa 100644 --- a/src/content-script/storage/storageSchema.ts +++ b/src/content-script/storage/storageSchema.ts @@ -78,10 +78,7 @@ export type AssetLockFundingPurpose = 'registration' | 'topUp' export interface AssetLockFundingAddressSchema { address: string - // Null when the asset lock is funded from the wallet's own coins: the inputs - // are ordinary BIP44 outputs, signed with a key derived from the seed, so there - // is no one-off key to keep. - encryptedPrivateKey: string | null + encryptedPrivateKey: string used: boolean assetLockTxid?: string | null // DIP-13 derivation index for top-up funding keys (m/9'/coin'/5'/2'/index). diff --git a/src/types/CoreUtxo.ts b/src/types/CoreUtxo.ts deleted file mode 100644 index f5af02a8..00000000 --- a/src/types/CoreUtxo.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { CoreAddressEntry } from '../utils/coreAddresses' - -export interface CoreUtxo extends CoreAddressEntry { - txid: string - vout: number - amount: string - confirmations?: number - isInstantLocked?: boolean - isChainLocked?: boolean -} diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index 0f7c3f5c..373f5463 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -2,9 +2,9 @@ import { ext } from '../platform' import { MESSAGING_TIMEOUT, SHIELDED_PROVE_TIMEOUT, BLOCKCHAIN_MESSAGING_TIMEOUT } from '../constants' import { EventData } from './EventData' import { NetworkType } from './NetworkType' +import { ListCoreUtxosResponse } from './messages/response/ListCoreUtxosResponse' import { GetCoreAddressesResponse } from './messages/response/GetCoreAddressesResponse' import { GetCoreBalanceResponse } from './messages/response/GetCoreBalanceResponse' -import { ListCoreUtxosResponse } from './messages/response/ListCoreUtxosResponse' import { GetCoreTransactionsPayload } from './messages/payloads/GetCoreTransactionsPayload' import { GetCoreTransactionsResponse } from './messages/response/GetCoreTransactionsResponse' import { InitAccountXpubsPayload } from './messages/payloads/InitAccountXpubsPayload' @@ -455,10 +455,12 @@ export class PrivateAPIClient { return response.addresses } - // The account's spendable Core outputs, for picking which coins fund an asset - // lock. No password: the xpub is cached. - async listCoreUtxos (): Promise { - return await this._rpcCall(MessagingMethods.LIST_CORE_UTXOS, {}) + async listCoreUtxos (): Promise { + const payload: EmptyPayload = {} + + const response: ListCoreUtxosResponse = await this._rpcCall(MessagingMethods.LIST_CORE_UTXOS, payload) + + return response.utxos } async getCoreBalance (): Promise { diff --git a/src/types/messages/payloads/RegisterIdentityPayload.ts b/src/types/messages/payloads/RegisterIdentityPayload.ts index 763b6d14..0c240ab8 100644 --- a/src/types/messages/payloads/RegisterIdentityPayload.ts +++ b/src/types/messages/payloads/RegisterIdentityPayload.ts @@ -1,13 +1,8 @@ export interface RegisterIdentityPayload { - /** - * The P2PKH address whose outputs fund the asset lock: either a one-off - * deposit address this extension handed out, or one of the wallet's own Core - * addresses, in which case its key comes from the seed and the caller picks - * which coins to spend (LIST_CORE_UTXOS shows them). - */ + /** The asset lock funding P2PKH address that received the payment */ assetLockFundingAddress: string - /** Txid of the transaction that paid to that address */ + /** Txid of the asset lock funding transaction that paid to the address */ assetLockFundingTxid: string - /** Extension password: decrypts the one-off key, or the seed */ + /** Extension password used to decrypt the asset lock funding private key */ password: string } diff --git a/src/types/messages/payloads/TopUpIdentityPayload.ts b/src/types/messages/payloads/TopUpIdentityPayload.ts index d7f1ac80..4acd48aa 100644 --- a/src/types/messages/payloads/TopUpIdentityPayload.ts +++ b/src/types/messages/payloads/TopUpIdentityPayload.ts @@ -2,9 +2,6 @@ import { NetworkType } from '../../NetworkType' export interface TopUpIdentityPayload { identityId: string - // Either a one-off deposit address this extension handed out, or one of the - // wallet's own Core addresses - then its key comes from the seed and the caller - // picks which coins to spend (LIST_CORE_UTXOS shows them). assetLockFundingAddress: string assetLockFundingTxid: string password: string diff --git a/src/types/messages/response/ListCoreUtxosResponse.ts b/src/types/messages/response/ListCoreUtxosResponse.ts index 8b867143..b72938af 100644 --- a/src/types/messages/response/ListCoreUtxosResponse.ts +++ b/src/types/messages/response/ListCoreUtxosResponse.ts @@ -1,6 +1,4 @@ export interface ListCoreUtxosResponse { - // One entry per spendable output of the account. `address` and `txid` are what - // an asset lock is funded with; every output of that transaction paying to that - // address is spent together, which is why the amount is shown per output. + // Amounts are strings: bigint does not cross the messaging boundary. utxos: Array<{ address: string, txid: string, vout: number, amountDuffs: string }> } diff --git a/src/utils/coreAddresses.ts b/src/utils/coreAddresses.ts index 1f9187f4..bcb77d4b 100644 --- a/src/utils/coreAddresses.ts +++ b/src/utils/coreAddresses.ts @@ -4,7 +4,7 @@ import { Network, PrivateKeyWASM } from 'dash-platform-sdk/types' import { Wallet } from '../types/Wallet' import { NetworkType } from '../types/NetworkType' import { CoreAddressChain } from '../types/enums/CoreAddressChain' -import { CORE_ADDRESS_WINDOW, CORE_BIP32_VERSIONS } from '../constants' +import { CORE_BIP32_VERSIONS } from '../constants' import { decryptMnemonic, deriveWalletHdKey } from './index' // BIP44 derivation for Core (L1) addresses: m/44'/coin'/account'/chain/index. @@ -81,34 +81,36 @@ export const deriveCoreAddressesFromXpub = ( return entries } -// The private key of one of the wallet's own Core addresses, found by walking both -// chains of the account. Funding an asset lock with the wallet's own coins needs -// it: the inputs are ordinary BIP44 outputs, not a one-off deposit, so the key -// comes from the seed rather than from storage. An address outside the window is -// not one this wallet hands out, and is refused rather than signed for. -export const deriveCoreAddressKey = async ( +// Private key of one of the wallet's own Core addresses, for signing an input +// that spends its coins. Refuses an address the account does not derive, so a +// caller cannot make the wallet sign for coins that are not its own. +// `nextUnused` is the explorer's gap scan; an address that received coins always +// sits below it. +export const deriveCoreAddressPrivateKey = async ( wallet: Wallet, password: string, xpub: string, address: string, + nextUnused: Record, sdk: DashPlatformSDK, account: number = 0 ): Promise => { - const chains = [CoreAddressChain.receiving, CoreAddressChain.change] - const entry = chains - .flatMap(chain => deriveCoreAddressesFromXpub(sdk, xpub, wallet.network, account, chain, CORE_ADDRESS_WINDOW)) - .find(candidate => candidate.address === address) + for (const chain of [CoreAddressChain.receiving, CoreAddressChain.change]) { + const entry = deriveCoreAddressesFromXpub(sdk, xpub, wallet.network, account, chain, nextUnused[chain] + 1) + .find(candidate => candidate.address === address) - if (entry == null) { - throw new Error(`Core address ${address} is not one of this wallet's own addresses`) - } + if (entry == null) { + continue + } + + const { privateKey } = await sdk.keyPair.derivePath(deriveWalletHdKey(wallet, password, sdk), entry.derivationPath) - const root = deriveWalletHdKey(wallet, password, sdk) - const derived = await sdk.keyPair.derivePath(root, entry.derivationPath) + if (privateKey == null) { + throw new Error(`Could not derive the private key of Core address ${address}`) + } - if (derived.privateKey == null) { - throw new Error(`Could not derive the key for Core address ${address}`) + return PrivateKeyWASM.fromBytes(privateKey, wallet.network) } - return PrivateKeyWASM.fromBytes(derived.privateKey, wallet.network) + throw new Error(`Core address ${address} is not one of this wallet's own addresses`) } diff --git a/test/api/private/identities/assetLockFromOwnCoins.spec.ts b/test/api/private/identities/assetLockFromOwnCoins.spec.ts new file mode 100644 index 00000000..c4933537 --- /dev/null +++ b/test/api/private/identities/assetLockFromOwnCoins.spec.ts @@ -0,0 +1,233 @@ +import { PrivateKeyWASM } from 'dash-platform-sdk/types' +import { RegisterIdentityHandler } from '../../../../src/content-script/api/private/identities/registerIdentity' +import { TopUpIdentityHandler } from '../../../../src/content-script/api/private/identities/topUpIdentity' +import { buildAssetLockFromFundingTx } from '../../../../src/utils/buildAssetLockFromFundingTx' +import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' +import { deriveCoreAddressPrivateKey } from '../../../../src/utils/coreAddresses' +import { WalletType } from '../../../../src/types' +import { IdentityType } from '../../../../src/types/enums/IdentityType' + +jest.mock('../../../../src/utils/buildAssetLockFromFundingTx', () => ({ + buildAssetLockFromFundingTx: jest.fn() +})) + +jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ + waitForAssetLockProof: jest.fn() +})) + +jest.mock('../../../../src/utils/coreAddresses', () => ({ + ...jest.requireActual('../../../../src/utils/coreAddresses'), + deriveCoreAccountXpub: jest.fn(async () => 'tpub'), + deriveCoreAddressPrivateKey: jest.fn() +})) + +jest.mock('../../../../src/utils/identityRegistration', () => ({ + IDENTITY_KEY_DEFINITIONS: [{ id: 0 }], + buildIdentityCreateTransition: jest.fn() +})) + +jest.mock('../../../../src/utils', () => ({ + ...jest.requireActual('../../../../src/utils'), + deriveIdentityPrivateKey: jest.fn(), + deriveIdentityRegistrationKey: jest.fn(), + deriveWalletHdKey: jest.fn(() => ({})), + deriveTopUpKeyFromHdKey: jest.fn() +})) + +const buildAssetLockFromFundingTxMock = buildAssetLockFromFundingTx as jest.MockedFunction +const waitForAssetLockProofMock = waitForAssetLockProof as jest.MockedFunction +const deriveCoreAddressPrivateKeyMock = deriveCoreAddressPrivateKey as jest.MockedFunction +const { buildIdentityCreateTransition } = jest.requireMock('../../../../src/utils/identityRegistration') +const { deriveIdentityRegistrationKey, deriveIdentityPrivateKey, deriveTopUpKeyFromHdKey } = jest.requireMock('../../../../src/utils') + +// Funding an asset lock with the wallet's own Core output: the caller names the +// address and the transaction that paid it, exactly as for a deposit. There is +// no record to read, so the key comes from the seed and L1 is asked what an +// earlier attempt committed. +describe('an asset lock funded from the wallet own coins', () => { + const identifier = 'HT3pUBM1Uv2mKgdPEN1gxa7A4PdsvNY89aJbdSKQb5wR' + const ownAddress = 'yTtgx2GriUKCECox9NWe9eutk7hFU5Hb8j' + const creditAddress = 'yjLG5HeifV72L78cr6EW4sEC9AATZnmLXA' + const fundingTxid = 'a'.repeat(64) + const assetLockTxid = 'b'.repeat(64) + const password = 'test' + + const key = PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet') + + let walletRepository: any + let identitiesRepository: any + let assetLockFundingAddressesRepository: any + let coreExplorer: any + let coreSDK: any + let sdk: any + let stateTransition: any + + beforeEach(() => { + jest.clearAllMocks() + + walletRepository = { + getCurrent: jest.fn(async () => ({ + walletId: 'wallet1', + network: 'testnet', + type: WalletType.seedphrase, + encryptedMnemonic: 'encryptedMnemonic', + seedHash: null, + label: null, + currentIdentity: identifier + })), + getCoreAccountXpub: jest.fn(async () => 'tpub'), + switchIdentity: jest.fn(async () => {}) + } + + identitiesRepository = { + getByIdentifier: jest.fn(async () => ({ identifier, index: 0, label: null, proTxHash: null, type: IdentityType.regular })), + create: jest.fn(async () => ({ identifier })), + remove: jest.fn(async () => {}), + getAll: jest.fn(async () => []) + } + + assetLockFundingAddressesRepository = { + getByAddress: jest.fn(async () => null), + create: jest.fn(async () => {}), + markAsBroadcasted: jest.fn(async () => {}), + markAsUsed: jest.fn(async () => {}) + } + + coreExplorer = { + getXpubSummary: jest.fn(async () => ({ nextUnused: { receiving: 3, change: 0 } })), + getOutputSpender: jest.fn(async () => null), + isAddressUsed: jest.fn(async () => false) + } + + coreSDK = { + broadcastTransaction: jest.fn(async () => {}), + getTransaction: jest.fn(async () => null), + subscribeToTransactions: jest.fn(() => ({ close: jest.fn() })) + } + + stateTransition = { + getOwnerId: () => ({ base58: () => identifier }), + hash: () => 'stateTransitionHash', + signByPrivateKey: jest.fn() + } + + coreSDK.network = 'testnet' + + sdk = { + getNetwork: () => 'testnet', + keyPair: { p2pkhAddress: jest.fn(() => creditAddress) }, + identities: { + getIdentityByPublicKeyHash: jest.fn(async () => null), + getIdentityByNonUniquePublicKeyHash: jest.fn(async () => null), + createStateTransition: jest.fn(() => stateTransition) + }, + stateTransitions: { + broadcast: jest.fn(async () => {}), + waitForStateTransitionResult: jest.fn(async () => {}) + } + } + + deriveCoreAddressPrivateKeyMock.mockResolvedValue(key) + deriveIdentityRegistrationKey.mockResolvedValue(key) + deriveIdentityPrivateKey.mockResolvedValue(key) + deriveTopUpKeyFromHdKey.mockResolvedValue(key) + buildIdentityCreateTransition.mockReturnValue(stateTransition) + buildAssetLockFromFundingTxMock.mockResolvedValue({ + assetLockTx: { hash: () => assetLockTxid, bytes: () => Uint8Array.from([1]) }, + lockedAmount: 100000000n + } as any) + waitForAssetLockProofMock.mockResolvedValue({ type: 'instantLock' } as any) + }) + + const register = async (): Promise => await new RegisterIdentityHandler( + walletRepository, identitiesRepository, assetLockFundingAddressesRepository, {} as any, sdk, coreSDK, coreExplorer + ).handle({ + context: 'dash-platform-extension', + id: 'id', + method: 'REGISTER_IDENTITY', + type: 'request', + payload: { password, assetLockFundingAddress: ownAddress, assetLockFundingTxid: fundingTxid } + } as any) + + const topUp = async (): Promise => await new TopUpIdentityHandler( + { ...walletRepository, forScope: () => walletRepository }, + { ...identitiesRepository, forScope: () => identitiesRepository }, + { ...assetLockFundingAddressesRepository, forScope: () => assetLockFundingAddressesRepository }, + sdk, coreSDK, coreExplorer + ).handle({ + context: 'dash-platform-extension', + id: 'id', + method: 'TOP_UP_IDENTITY', + type: 'request', + payload: { identityId: identifier, password, assetLockFundingAddress: ownAddress, assetLockFundingTxid: fundingTxid } + } as any) + + describe('registration', () => { + it('signs the asset lock with the key of the wallet own address and stores nothing', async () => { + const result = await register() + + expect(result.identifier).toBe(identifier) + expect(deriveCoreAddressPrivateKeyMock).toHaveBeenCalledWith( + expect.anything(), password, 'tpub', ownAddress, { receiving: 3, change: 0 }, sdk + ) + // Same builder and same arguments as a deposit: only the key differs. + expect(buildAssetLockFromFundingTxMock).toHaveBeenCalledWith( + coreSDK, fundingTxid, ownAddress, key.WIF(), creditAddress + ) + expect(coreSDK.broadcastTransaction).toHaveBeenCalled() + expect(assetLockFundingAddressesRepository.create).not.toHaveBeenCalled() + expect(assetLockFundingAddressesRepository.markAsBroadcasted).not.toHaveBeenCalled() + expect(assetLockFundingAddressesRepository.markAsUsed).not.toHaveBeenCalled() + }) + + it('rebuilds the asset lock L1 already holds and does not broadcast it twice', async () => { + coreExplorer.getOutputSpender.mockResolvedValue(assetLockTxid) + + const result = await register() + + expect(result.identifier).toBe(identifier) + expect(coreExplorer.getOutputSpender).toHaveBeenCalledWith(fundingTxid, ownAddress, 'testnet') + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() + }) + + it('refuses an address the wallet does not own', async () => { + deriveCoreAddressPrivateKeyMock.mockRejectedValue(new Error("Core address yfoo is not one of this wallet's own addresses")) + + await expect(register()).rejects.toThrow(/not one of this wallet/) + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() + }) + }) + + describe('top-up', () => { + it('spends the own output and sends the credits to a DIP-13 top-up key', async () => { + const result = await topUp() + + expect(result.stateTransitionHash).toBe('stateTransitionHash') + expect(deriveTopUpKeyFromHdKey).toHaveBeenCalledWith(expect.anything(), 'testnet', 0, sdk) + expect(buildAssetLockFromFundingTxMock).toHaveBeenCalledWith( + coreSDK, fundingTxid, ownAddress, key.WIF(), creditAddress + ) + // The credit key, not the paying address key, signs the state transition. + expect(stateTransition.signByPrivateKey).toHaveBeenCalledWith(key, undefined, expect.anything()) + expect(assetLockFundingAddressesRepository.markAsUsed).not.toHaveBeenCalled() + }) + + it('takes the next top-up index that has never appeared on L1', async () => { + coreExplorer.isAddressUsed.mockResolvedValueOnce(true) + + await topUp() + + expect(deriveTopUpKeyFromHdKey).toHaveBeenNthCalledWith(1, expect.anything(), 'testnet', 0, sdk) + expect(deriveTopUpKeyFromHdKey).toHaveBeenNthCalledWith(2, expect.anything(), 'testnet', 1, sdk) + }) + + it('recovers the credit key of an asset lock L1 already holds', async () => { + coreExplorer.getOutputSpender.mockResolvedValue(assetLockTxid) + + await topUp() + + expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() + expect(coreExplorer.isAddressUsed).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/api/private/identities/registerIdentity.spec.ts b/test/api/private/identities/registerIdentity.spec.ts index 4294f853..98d80579 100644 --- a/test/api/private/identities/registerIdentity.spec.ts +++ b/test/api/private/identities/registerIdentity.spec.ts @@ -56,6 +56,7 @@ describe('RegisterIdentityHandler', () => { let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any + let coreExplorer: any let sdk: any let handler: RegisterIdentityHandler let encryptedPrivateKey: string @@ -121,6 +122,12 @@ describe('RegisterIdentityHandler', () => { }) } + coreExplorer = { + getOutputSpender: jest.fn(async () => null), + getXpubSummary: jest.fn(async () => ({ nextUnused: { receiving: 0, change: 0 } })), + isAddressUsed: jest.fn(async () => false) + } + coreSDK = { subscribeToTransactions: jest.fn(() => { order.push('subscribe') @@ -175,7 +182,8 @@ describe('RegisterIdentityHandler', () => { assetLockFundingAddressesRepository, {} as any, sdk, - coreSDK + coreSDK, + coreExplorer ) }) diff --git a/test/api/private/identities/registerIdentityOwnCoins.spec.ts b/test/api/private/identities/registerIdentityOwnCoins.spec.ts deleted file mode 100644 index f513a5fa..00000000 --- a/test/api/private/identities/registerIdentityOwnCoins.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { PrivateKeyWASM } from 'dash-platform-sdk/types' -import { RegisterIdentityHandler } from '../../../../src/content-script/api/private/identities/registerIdentity' -import { buildAssetLockFromFundingTx } from '../../../../src/utils/buildAssetLockFromFundingTx' -import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' -import { deriveCoreAddressKey } from '../../../../src/utils/coreAddresses' -import { WalletType } from '../../../../src/types' - -jest.mock('../../../../src/utils/buildAssetLockFromFundingTx', () => ({ - buildAssetLockFromFundingTx: jest.fn() -})) - -jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ - waitForAssetLockProof: jest.fn() -})) - -jest.mock('../../../../src/utils/coreAddresses', () => { - const actual = jest.requireActual('../../../../src/utils/coreAddresses') - return { - ...actual, - deriveCoreAddressKey: jest.fn() - } -}) - -jest.mock('../../../../src/utils/identityRegistration', () => ({ - IDENTITY_KEY_DEFINITIONS: [{ id: 0 }], - buildIdentityCreateTransition: jest.fn() -})) - -jest.mock('../../../../src/utils', () => { - const actual = jest.requireActual('../../../../src/utils') - return { - ...actual, - deriveIdentityPrivateKey: jest.fn(), - deriveIdentityRegistrationKey: jest.fn() - } -}) - -const buildAssetLockFromFundingTxMock = buildAssetLockFromFundingTx as jest.MockedFunction -const waitForAssetLockProofMock = waitForAssetLockProof as jest.MockedFunction -const deriveCoreAddressKeyMock = deriveCoreAddressKey as jest.MockedFunction -const { buildIdentityCreateTransition } = jest.requireMock('../../../../src/utils/identityRegistration') -const { deriveIdentityRegistrationKey, deriveIdentityPrivateKey } = jest.requireMock('../../../../src/utils') - -// Funding an asset lock with one of the wallet's own Core outputs: the caller -// names the address and the transaction that paid it, exactly as it does for a -// deposit, and the key comes from the seed instead of a stored one-off entry. -// Everything after the key is develop's pipeline, so what is covered here is the -// key, the record, and that nothing else changed shape. -describe('RegisterIdentityHandler funded from an own Core output', () => { - const identifier = 'HT3pUBM1Uv2mKgdPEN1gxa7A4PdsvNY89aJbdSKQb5wR' - const ownAddress = 'yTtgx2GriUKCECox9NWe9eutk7hFU5Hb8j' - const creditAddress = 'yjLG5HeifV72L78cr6EW4sEC9AATZnmLXA' - const fundingTxid = 'a'.repeat(64) - const assetLockTxid = 'b'.repeat(64) - const password = 'test' - - let stored: any[] - let walletRepository: any - let identitiesRepository: any - let assetLockFundingAddressesRepository: any - let coreSDK: any - let sdk: any - let handler: RegisterIdentityHandler - - beforeEach(() => { - jest.clearAllMocks() - stored = [] - - walletRepository = { - getCurrent: jest.fn(async () => ({ - walletId: 'wallet1', - network: 'testnet', - type: WalletType.seedphrase, - encryptedMnemonic: 'encryptedMnemonic', - seedHash: null, - label: null, - currentIdentity: null - })), - switchIdentity: jest.fn(async () => {}), - getCoreAccountXpub: jest.fn(async () => 'xpub') - } - - identitiesRepository = { - getByIdentifier: jest.fn(async () => null), - create: jest.fn(async () => ({ identifier })), - remove: jest.fn(async () => {}), - getAll: jest.fn(async () => []) - } - - assetLockFundingAddressesRepository = { - findAllUnused: jest.fn(async () => stored.filter(entry => entry.used !== true)), - getByAddress: jest.fn(async (address: string) => stored.find(entry => entry.address === address) ?? null), - create: jest.fn(async (entry: any) => { - stored.push(entry) - return entry - }), - markAsBroadcasted: jest.fn(async (address: string, txid: string, index?: number) => { - const entry = stored.find(candidate => candidate.address === address) - entry.assetLockTxid = txid - entry.registrationIdentityIndex = index - }), - markAsUsed: jest.fn(async (address: string) => { - stored.find(candidate => candidate.address === address).used = true - }) - } - - coreSDK = { - broadcastTransaction: jest.fn(async () => {}), - subscribeToTransactions: jest.fn(() => ({ async * [Symbol.asyncIterator] () {} })) - } - - sdk = { - keyPair: { p2pkhAddress: jest.fn(() => creditAddress) }, - identities: { - getIdentityByPublicKeyHash: jest.fn(async () => null), - getIdentityByNonUniquePublicKeyHash: jest.fn(async () => null) - }, - stateTransitions: { - broadcast: jest.fn(async () => {}), - waitForStateTransitionResult: jest.fn(async () => {}) - } - } - - const key = PrivateKeyWASM.fromHex('3ca33236ab14f6df6cf87fcbb0551544fee7dcf4f251557af02c175725764a5a', 'testnet') - - deriveCoreAddressKeyMock.mockResolvedValue(key) - deriveIdentityRegistrationKey.mockResolvedValue(key) - deriveIdentityPrivateKey.mockResolvedValue(key) - buildAssetLockFromFundingTxMock.mockResolvedValue({ - assetLockTx: { hash: () => assetLockTxid, bytes: () => Uint8Array.from([1]) }, - lockedAmount: 100000000n - } as any) - buildIdentityCreateTransition.mockReturnValue({ - getOwnerId: () => ({ base58: () => identifier }), - hash: () => 'stateTransitionHash', - signByPrivateKey: jest.fn() - }) - waitForAssetLockProofMock.mockResolvedValue({ type: 'instantLock', transaction: 't', instantLock: 'l', outputIndex: 0 } as any) - - handler = new RegisterIdentityHandler( - walletRepository, - identitiesRepository, - assetLockFundingAddressesRepository, - {} as any, - sdk, - coreSDK - ) - }) - - const handle = async (payload: any = {}): Promise => await handler.handle({ - context: 'dash-platform-extension', - id: 'id', - method: 'REGISTER_IDENTITY', - type: 'request', - payload: { password, assetLockFundingAddress: ownAddress, assetLockFundingTxid: fundingTxid, ...payload } - } as any) - - it('signs the asset lock with the key of the wallet own address', async () => { - const result = await handle() - - expect(result.identifier).toBe(identifier) - expect(deriveCoreAddressKeyMock).toHaveBeenCalledWith(expect.anything(), password, 'xpub', ownAddress, sdk) - // The transaction is built exactly as for a deposit: same builder, same - // arguments, only the key is derived rather than decrypted. - expect(buildAssetLockFromFundingTxMock).toHaveBeenCalledWith( - coreSDK, fundingTxid, ownAddress, expect.any(String), creditAddress - ) - }) - - it('opens the record and pins the identity index before broadcasting', async () => { - await handle() - - // Keyed by the credit output address, so the paying address stays reusable. - expect(assetLockFundingAddressesRepository.create).toHaveBeenCalledWith(expect.objectContaining({ - address: creditAddress, - encryptedPrivateKey: null, - registrationIdentityIndex: 0, - purpose: 'registration' - })) - expect(assetLockFundingAddressesRepository.create.mock.invocationCallOrder[0]) - .toBeLessThan(coreSDK.broadcastTransaction.mock.invocationCallOrder[0]) - expect(assetLockFundingAddressesRepository.markAsBroadcasted) - .toHaveBeenCalledWith(creditAddress, assetLockTxid, 0) - }) - - it('rebuilds the same asset lock on a retry and does not send it twice', async () => { - await handle() - stored[0].used = false - coreSDK.broadcastTransaction.mockClear() - deriveIdentityRegistrationKey.mockClear() - - const result = await handle() - - expect(result.identifier).toBe(identifier) - // The pinned index is reused, so the rebuilt transaction is the same one, and - // the asset lock already on L1 is not broadcast again. - for (const call of deriveIdentityRegistrationKey.mock.calls) { - expect(call[2]).toBe(0) - } - expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() - }) - - it('refuses an address the wallet does not own', async () => { - deriveCoreAddressKeyMock.mockRejectedValue(new Error("Core address yfoo is not one of this wallet's own addresses")) - - await expect(handle({ assetLockFundingAddress: 'yfoo' })).rejects.toThrow(/not one of this wallet/) - expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() - }) -}) diff --git a/test/api/private/identities/topUpIdentity.spec.ts b/test/api/private/identities/topUpIdentity.spec.ts index e966cf4f..5bd1f23a 100644 --- a/test/api/private/identities/topUpIdentity.spec.ts +++ b/test/api/private/identities/topUpIdentity.spec.ts @@ -7,6 +7,7 @@ import { StorageAdapter } from '../../../../src/content-script/storage/storageAd import { bytesToHex, hexToBytes } from '../../../../src/utils' import { buildAssetLockFromFundingTx } from '../../../../src/utils/buildAssetLockFromFundingTx' import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' +import { deriveCoreAddressPrivateKey } from '../../../../src/utils/coreAddresses' import { WalletType } from '../../../../src/types' import { IdentityType } from '../../../../src/types/enums/IdentityType' @@ -18,8 +19,14 @@ jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ waitForAssetLockProof: jest.fn() })) +jest.mock('../../../../src/utils/coreAddresses', () => ({ + ...jest.requireActual('../../../../src/utils/coreAddresses'), + deriveCoreAddressPrivateKey: jest.fn() +})) + const buildAssetLockFromFundingTxMock = buildAssetLockFromFundingTx as jest.MockedFunction const waitForAssetLockProofMock = waitForAssetLockProof as jest.MockedFunction +const deriveCoreAddressPrivateKeyMock = deriveCoreAddressPrivateKey as jest.MockedFunction class TestStorageAdapter implements StorageAdapter { cache: Record = {} @@ -87,8 +94,6 @@ describe('TopUpIdentityHandler', () => { } walletRepository = { - // A real testnet account xpub: the own-address check expands it for real. - getCoreAccountXpub: jest.fn(async () => 'tpubDDfiaQD79RwqzH87Zb9rCKY3ETVrasx1aWXs8KrCTEpcgScuFig9UYFYCkH4D94xvG5BenQrFAz7PrqNEjHeDx3tiJC6dDk1JtvPBev3NjS'), getCurrent: jest.fn(async () => ({ walletId: 'wallet1', type: WalletType.keystore, @@ -97,7 +102,8 @@ describe('TopUpIdentityHandler', () => { encryptedMnemonic: null, seedHash: null, currentIdentity: identityId - })) + })), + getCoreAccountXpub: jest.fn(async () => 'tpub') } identitiesRepository = { @@ -111,7 +117,6 @@ describe('TopUpIdentityHandler', () => { } assetLockFundingAddressesRepository = { - findAllUnused: jest.fn(async () => []), getByAddress: jest.fn(async () => ({ address: assetLockFundingAddress, encryptedPrivateKey, @@ -132,7 +137,12 @@ describe('TopUpIdentityHandler', () => { identitiesRepository.forScope = jest.fn(() => identitiesRepository) assetLockFundingAddressesRepository.forScope = jest.fn(() => assetLockFundingAddressesRepository) - coreExplorer = { isAddressUsed: jest.fn(async () => false) } + coreExplorer = { + getOutputSpender: jest.fn(async () => null), + getXpubSummary: jest.fn(async () => ({ nextUnused: { receiving: 0, change: 0 } })), + isAddressUsed: jest.fn(async () => false) + } + coreSDK = { // Both SDKs are fixed to a network for the lifetime of their document, and // the handler refuses to run against a scope they cannot serve. @@ -148,9 +158,6 @@ describe('TopUpIdentityHandler', () => { sdk = { getNetwork: jest.fn(() => 'testnet'), - // Every derived address differs from the funding one, so an address with no - // record reads as "not the wallet's own". - keyPair: { p2pkhAddress: jest.fn(() => 'yOtherAddressOfThisWallet') }, identities: { createStateTransition: jest.fn(() => stateTransition) }, @@ -255,10 +262,15 @@ describe('TopUpIdentityHandler', () => { expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() }) - test('rejects an address that is neither a deposit nor one of the wallet own', async () => { + // No record means the caller is paying with the wallet's own coins, which the + // own-coins suite covers. Here: an address that is neither is refused. + test('rejects a funding address the wallet does not own', async () => { assetLockFundingAddressesRepository.getByAddress.mockResolvedValue(null) + deriveCoreAddressPrivateKeyMock.mockRejectedValueOnce( + new Error(`Core address ${assetLockFundingAddress} is not one of this wallet's own addresses`) + ) - await expect(handle()).rejects.toThrow(/is not one of this wallet's own addresses/) + await expect(handle()).rejects.toThrow('is not one of this wallet') expect(assetLockFundingAddressesRepository.markAsBroadcasted).not.toHaveBeenCalled() expect(coreSDK.broadcastTransaction).not.toHaveBeenCalled() diff --git a/test/content-script/services/CoreExplorerService.spec.ts b/test/content-script/services/CoreExplorerService.spec.ts index 75013912..4a99678b 100644 --- a/test/content-script/services/CoreExplorerService.spec.ts +++ b/test/content-script/services/CoreExplorerService.spec.ts @@ -209,6 +209,74 @@ describe('CoreExplorerService', () => { }) }) + describe('getXpubUtxos', () => { + const page = (rows: Array<[string, string]>, total: number): unknown => ({ + resultSet: rows.map(([address, amount], index) => ({ address, prevTxHash: 'f'.repeat(64), vOutIndex: index, amount })), + pagination: { page: 1, limit: 100, total } + }) + + it('maps the account outputs and keeps the address that received each one', async () => { + mockResponse({ json: page([['yOwnA', '100000000'], ['yOwnB', '250']], 2) }) + + const utxos = await service.getXpubUtxos('tpubXpub', 'testnet') + + expect(fetchMock.mock.calls[0][0]).toBe(`${testnetBase}/xpub/utxo`) + expect(utxos).toEqual([ + { address: 'yOwnA', txid: 'f'.repeat(64), vout: 0, amount: 100000000n }, + { address: 'yOwnB', txid: 'f'.repeat(64), vout: 1, amount: 250n } + ]) + }) + + it('walks every page the explorer reports', async () => { + fetchMock + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => page(Array.from({ length: 100 }, () => ['yOwn', '1'] as [string, string]), 101) }) + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => page([['yOwn', '1']], 101) }) + + expect(await service.getXpubUtxos('tpubXpub', 'testnet')).toHaveLength(101) + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ xpub: 'tpubXpub', page: 2, limit: 100 }) + }) + + it('throws on a non-OK response', async () => { + mockResponse({ status: 500, json: {} }) + + await expect(service.getXpubUtxos('tpubXpub', 'testnet')).rejects.toThrow('HTTP 500') + }) + }) + + describe('getOutputSpender', () => { + const transaction = (outputs: unknown[]): unknown => ({ hash: 'a'.repeat(64), vOut: outputs }) + + it('names the transaction that spent the output paying the address', async () => { + mockResponse({ + json: transaction([ + { number: 0, address: 'yOther', spentTxId: 'c'.repeat(64) }, + { number: 1, address: 'yOwn', spentTxId: 'd'.repeat(64) } + ]) + }) + + expect(await service.getOutputSpender('a'.repeat(64), 'yOwn', 'testnet')).toBe('d'.repeat(64)) + expect(fetchMock).toHaveBeenCalledWith(`${testnetBase}/transaction/${'a'.repeat(64)}`) + }) + + it('is null while the output is unspent', async () => { + mockResponse({ json: transaction([{ number: 0, address: 'yOwn', spentTxId: null }]) }) + + expect(await service.getOutputSpender('a'.repeat(64), 'yOwn', 'testnet')).toBeNull() + }) + + it('is null for a transaction the explorer does not know (404)', async () => { + mockResponse({ status: 404, json: { error: 'Transaction not found' } }) + + expect(await service.getOutputSpender('a'.repeat(64), 'yOwn', 'testnet')).toBeNull() + }) + + it('throws on other non-OK responses', async () => { + mockResponse({ status: 500, json: {} }) + + await expect(service.getOutputSpender('a'.repeat(64), 'yOwn', 'testnet')).rejects.toThrow('HTTP 500') + }) + }) + describe('getXpubTransactions', () => { // Trimmed from the explorer's real reply for testnet transaction 96a08147… const confirmed = {