diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 3a71eacb..bad236af 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -51,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' @@ -164,19 +165,22 @@ export class PrivateAPI { assetLockFundingAddressesRepository, this.storageAdapter, this.sdk, - this.coreSDK + this.coreSDK, + coreExplorer ), [MessagingMethods.TOP_UP_IDENTITY]: new TopUpIdentityHandler( walletRepository, identitiesRepository, assetLockFundingAddressesRepository, this.sdk, - this.coreSDK + this.coreSDK, + 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..d104047e --- /dev/null +++ b/src/content-script/api/private/core/listCoreUtxos.ts @@ -0,0 +1,51 @@ +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 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 + + 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) { + return { utxos: [] } + } + + const utxos = await this.coreExplorer.getXpubUtxos(xpub, wallet.network as NetworkType) + + 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 34569e73..7c9ada0a 100644 --- a/src/content-script/api/private/identities/registerIdentity.ts +++ b/src/content-script/api/private/identities/registerIdentity.ts @@ -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 { 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 { @@ -35,6 +38,7 @@ export class RegisterIdentityHandler implements APIHandler { storageAdapter: StorageAdapter sdk: DashPlatformSDK coreSDK: DashCoreSDK + coreExplorer: CoreExplorerService constructor ( walletRepository: WalletRepository, @@ -42,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 @@ -50,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 { @@ -67,16 +73,12 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 2. Load the asset lock funding address entry ──────────────────────── + // 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 == null) { - throw new Error( - `Asset lock funding address ${payload.assetLockFundingAddress} not found. ` + - 'It may belong to a different wallet or network.' - ) - } - - if (assetLockFundingAddressEntry.used) { + if (assetLockFundingAddressEntry?.used === true) { throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used for registration`) } @@ -84,17 +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 passwordHash = hash.sha256().update(payload.password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) + let assetLockFundingPrivateKey: PrivateKeyWASM - 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 == 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) - const assetLockFundingPrivateKey = PrivateKeyWASM.fromBytes(assetLockFundingKeyBytes, wallet.network) + 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 @@ -105,10 +119,16 @@ 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) { + } else if (assetLockFundingAddressEntry?.registrationIdentityIndex != null) { // Recovery: reuse the index pinned when the asset lock was broadcast. identityIndex = assetLockFundingAddressEntry.registrationIdentityIndex } else { @@ -119,7 +139,7 @@ export class RegisterIdentityHandler implements APIHandler { wallet, payload, assetLockFundingPrivateKey, - assetLockFundingAddressEntry.assetLockTxid + committedAssetLockTxid ) } @@ -142,13 +162,10 @@ 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})` ) } @@ -161,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(payload.assetLockFundingAddress, 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) ────── @@ -259,7 +288,10 @@ export class RegisterIdentityHandler implements APIHandler { } // ── 15. Mark funding address as used and switch identity ──────────────── - await this.assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + if (assetLockFundingAddressEntry != null) { + await this.assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + } + await this.walletRepository.switchIdentity(identifier) return { diff --git a/src/content-script/api/private/identities/topUpIdentity.ts b/src/content-script/api/private/identities/topUpIdentity.ts index 1418ebf5..15d011d5 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 { 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 { 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 { 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,19 +29,22 @@ export class TopUpIdentityHandler implements APIHandler { assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository sdk: DashPlatformSDK coreSDK: DashCoreSDK + coreExplorer: CoreExplorerService constructor ( walletRepository: WalletRepository, identitiesRepository: IdentitiesRepository, assetLockFundingAddressesRepository: AssetLockFundingAddressesRepository, sdk: DashPlatformSDK, - coreSDK: DashCoreSDK + coreSDK: DashCoreSDK, + coreExplorer: CoreExplorerService ) { this.walletRepository = walletRepository this.identitiesRepository = identitiesRepository this.assetLockFundingAddressesRepository = assetLockFundingAddressesRepository this.sdk = sdk this.coreSDK = coreSDK + this.coreExplorer = coreExplorer } async handle (event: EventData): Promise { @@ -69,13 +76,11 @@ export class TopUpIdentityHandler implements APIHandler { throw new Error(`Identity ${payload.identityId} does not belong to wallet ${scope.walletId} on ${scope.network}`) } + // 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 == null) { - throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} not found`) - } - - if (assetLockFundingAddressEntry.used) { + if (assetLockFundingAddressEntry?.used === true) { throw new Error(`Asset lock funding address ${payload.assetLockFundingAddress} has already been used`) } @@ -83,7 +88,7 @@ export class TopUpIdentityHandler implements APIHandler { // 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( @@ -92,40 +97,64 @@ export class TopUpIdentityHandler implements APIHandler { ) } - const passwordHash = hash.sha256().update(payload.password).digest('hex') - const secretKey = PrivateKey.fromHex(passwordHash) + // 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 - 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` owns the credit output and signs the top-up + // 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 creditOutputAddress: string + + if (assetLockFundingAddressEntry == null) { + const xpub = await walletRepository.getCoreAccountXpub(0) ?? + await deriveCoreAccountXpub(wallet, payload.password, 0, this.sdk) + const { nextUnused } = await this.coreExplorer.getXpubSummary(xpub, wallet.network as NetworkType) + + 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.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) + inputPrivateKey = assetLockFundingPrivateKey + creditOutputAddress = 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, payload.assetLockFundingAddress, - assetLockFundingPrivateKey.WIF(), - payload.assetLockFundingAddress + inputPrivateKey.WIF(), + creditOutputAddress ) 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})` ) } @@ -137,11 +166,23 @@ export class TopUpIdentityHandler implements APIHandler { [txidToFilterBytes(assetLockTxid)] ) - 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) + 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 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( @@ -170,7 +211,9 @@ export class TopUpIdentityHandler implements APIHandler { } } - await assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + if (assetLockFundingAddressEntry != null) { + await assetLockFundingAddressesRepository.markAsUsed(payload.assetLockFundingAddress) + } return { identityId: payload.identityId, @@ -179,6 +222,57 @@ export class TopUpIdentityHandler implements APIHandler { } } + // 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 candidateAddress = this.sdk.keyPair.p2pkhAddress(candidate.getPublicKey().bytes(), wallet.network as Network) + + if (await assetLockFundingAddressesRepository.getByAddress(candidateAddress) != null) { + continue + } + + 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/services/CoreExplorerService.ts b/src/content-script/services/CoreExplorerService.ts index 42755b01..0e1cc746 100644 --- a/src/content-script/services/CoreExplorerService.ts +++ b/src/content-script/services/CoreExplorerService.ts @@ -26,6 +26,10 @@ export interface CoreAddressUtxo { amount: bigint } +export interface CoreXpubUtxo extends CoreAddressUtxo { + address: string +} + export interface CoreExplorerTransactionInput { // null for a coinbase input, which spends no address address: string | null @@ -212,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`. @@ -241,6 +278,35 @@ export class CoreExplorerService { return info != null && info.txCount > 0 } + // 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 response = await fetch(`${baseUrl}/transaction/${txid}`) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + throw new Error(`Core explorer error for transaction ${txid}: HTTP ${response.status}`) + } + + const data = await response.json() + const outputs: any[] = Array.isArray(data?.vOut) ? data.vOut : [] + + 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 // spendable outputs. Used for recovery, not for first-deposit detection. async getAddressUtxos (address: string, network: NetworkType = 'testnet'): Promise { diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index 14e856f2..373f5463 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -2,6 +2,7 @@ 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 { GetCoreTransactionsPayload } from './messages/payloads/GetCoreTransactionsPayload' @@ -454,6 +455,14 @@ export class PrivateAPIClient { return response.addresses } + async listCoreUtxos (): Promise { + const payload: EmptyPayload = {} + + const response: ListCoreUtxosResponse = await this._rpcCall(MessagingMethods.LIST_CORE_UTXOS, payload) + + return response.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/response/ListCoreUtxosResponse.ts b/src/types/messages/response/ListCoreUtxosResponse.ts new file mode 100644 index 00000000..b72938af --- /dev/null +++ b/src/types/messages/response/ListCoreUtxosResponse.ts @@ -0,0 +1,4 @@ +export interface ListCoreUtxosResponse { + // 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 c078ed7d..bcb77d4b 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 { decryptMnemonic, deriveWalletHdKey } from './index' // BIP44 derivation for Core (L1) addresses: m/44'/coin'/account'/chain/index. // @@ -80,3 +80,37 @@ export const deriveCoreAddressesFromXpub = ( return entries } + +// 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 => { + 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) { + continue + } + + const { privateKey } = await sdk.keyPair.derivePath(deriveWalletHdKey(wallet, password, sdk), entry.derivationPath) + + if (privateKey == null) { + throw new Error(`Could not derive the private key of Core address ${address}`) + } + + return PrivateKeyWASM.fromBytes(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/topUpIdentity.spec.ts b/test/api/private/identities/topUpIdentity.spec.ts index ff26f3a4..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 = {} @@ -61,6 +68,7 @@ describe('TopUpIdentityHandler', () => { let identitiesRepository: any let assetLockFundingAddressesRepository: any let coreSDK: any + let coreExplorer: any let sdk: any let handler: TopUpIdentityHandler let encryptedPrivateKey: string @@ -94,7 +102,8 @@ describe('TopUpIdentityHandler', () => { encryptedMnemonic: null, seedHash: null, currentIdentity: identityId - })) + })), + getCoreAccountXpub: jest.fn(async () => 'tpub') } identitiesRepository = { @@ -128,6 +137,12 @@ describe('TopUpIdentityHandler', () => { identitiesRepository.forScope = jest.fn(() => identitiesRepository) assetLockFundingAddressesRepository.forScope = jest.fn(() => assetLockFundingAddressesRepository) + 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. @@ -171,7 +186,8 @@ describe('TopUpIdentityHandler', () => { identitiesRepository, assetLockFundingAddressesRepository, sdk, - coreSDK + coreSDK, + coreExplorer ) }) @@ -246,10 +262,15 @@ describe('TopUpIdentityHandler', () => { expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() }) - test('rejects missing funding address', async () => { - assetLockFundingAddressesRepository.getByAddress.mockResolvedValueOnce(null) + // 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(`Asset lock funding address ${assetLockFundingAddress} not found`) + 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 = {