From 9b5d4a20d4d563aca98319e7172609092236b75f Mon Sep 17 00:00:00 2001 From: LexxXell Date: Tue, 22 Sep 2026 15:00:49 +0400 Subject: [PATCH 1/5] feat: fund identity registration and top-up from the wallet's own Core coins --- src/constants.ts | 2 +- src/content-script/api/PrivateAPI.ts | 39 ++ src/content-script/api/fundingConflicts.ts | 25 ++ .../identities/cancelIdentityFunding.ts | 47 +++ .../identities/executeIdentityFunding.ts | 275 ++++++++++++++ .../getIdentityFundingOperations.ts | 46 +++ .../identities/getIdentityFundingSources.ts | 41 +++ .../identities/identityFundingPayload.ts | 23 ++ .../identities/prepareIdentityFunding.ts | 211 +++++++++++ .../identities/registerIdentityFromCore.ts | 9 + .../identities/topUpIdentityFromCore.ts | 9 + .../migrations/0010_identity_funding.ts | 7 + .../AssetLockFundingAddressesRepository.ts | 4 + .../repository/IdentityFundingRepository.ts | 53 +++ .../repository/WalletRepository.ts | 2 +- .../services/CoreExplorerService.ts | 42 +++ .../services/IdentityFundingService.ts | 337 ++++++++++++++++++ src/content-script/storage/runMigrations.ts | 4 +- src/types/CoreUtxo.ts | 10 + src/types/IdentityFundingOperation.ts | 33 ++ src/types/PrivateAPIClient.ts | 34 ++ src/types/enums/MessagingMethods.ts | 6 + .../payloads/ExecuteIdentityFundingPayload.ts | 6 + .../payloads/PrepareIdentityFundingPayload.ts | 14 + .../GetIdentityFundingSourcesResponse.ts | 4 + src/utils/buildAssetLockFromUtxos.ts | 83 +++++ src/utils/identityFundingErrors.ts | 35 ++ src/utils/waitForAssetLockProof.ts | 39 +- .../identities/identityFunding.spec.ts | 225 ++++++++++++ test/content-script/fundingConflicts.spec.ts | 28 ++ .../services/CoreExplorerService.spec.ts | 31 ++ test/helpers/isolatedStorage.ts | 32 ++ test/storage/identityFundingMigration.spec.ts | 17 + test/utils/buildAssetLockFromUtxos.spec.ts | 86 +++++ test/utils/waitForAssetLockProof.spec.ts | 33 ++ 35 files changed, 1878 insertions(+), 14 deletions(-) create mode 100644 src/content-script/api/fundingConflicts.ts create mode 100644 src/content-script/api/private/identities/cancelIdentityFunding.ts create mode 100644 src/content-script/api/private/identities/executeIdentityFunding.ts create mode 100644 src/content-script/api/private/identities/getIdentityFundingOperations.ts create mode 100644 src/content-script/api/private/identities/getIdentityFundingSources.ts create mode 100644 src/content-script/api/private/identities/identityFundingPayload.ts create mode 100644 src/content-script/api/private/identities/prepareIdentityFunding.ts create mode 100644 src/content-script/api/private/identities/registerIdentityFromCore.ts create mode 100644 src/content-script/api/private/identities/topUpIdentityFromCore.ts create mode 100644 src/content-script/migrations/0010_identity_funding.ts create mode 100644 src/content-script/repository/IdentityFundingRepository.ts create mode 100644 src/content-script/services/IdentityFundingService.ts create mode 100644 src/types/CoreUtxo.ts create mode 100644 src/types/IdentityFundingOperation.ts create mode 100644 src/types/messages/payloads/ExecuteIdentityFundingPayload.ts create mode 100644 src/types/messages/payloads/PrepareIdentityFundingPayload.ts create mode 100644 src/types/messages/response/GetIdentityFundingSourcesResponse.ts create mode 100644 src/utils/buildAssetLockFromUtxos.ts create mode 100644 src/utils/identityFundingErrors.ts create mode 100644 test/api/private/identities/identityFunding.spec.ts create mode 100644 test/content-script/fundingConflicts.spec.ts create mode 100644 test/helpers/isolatedStorage.ts create mode 100644 test/storage/identityFundingMigration.spec.ts create mode 100644 test/utils/buildAssetLockFromUtxos.spec.ts create mode 100644 test/utils/waitForAssetLockProof.spec.ts diff --git a/src/constants.ts b/src/constants.ts index d3e25373..55d80b58 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -export const SCHEMA_VERSION = 9 +export const SCHEMA_VERSION = 10 export const MESSAGING_TIMEOUT = 3 * 60 * 1000 // Shielded (Orchard) Halo2 proofs are CPU-heavy and run well past the normal // timeout in the popup — give these calls a much longer window. diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 8fa31047..56db38c7 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -40,6 +40,14 @@ import { CreateStateTransitionHandler } from './private/stateTransitions/createS import { CreateIdentityPrivateKeyHandler } from './private/identities/createIdentityPrivateKey' import { AssetLockFundingAddressesRepository } from '../repository/AssetLockFundingAddressesRepository' import { CoreExplorerService } from '../services/CoreExplorerService' +import { IdentityFundingService } from '../services/IdentityFundingService' +import { canConflictWithFunding, findConflictingFunding } from './fundingConflicts' +import { PrepareIdentityFundingHandler } from './private/identities/prepareIdentityFunding' +import { GetIdentityFundingSourcesHandler } from './private/identities/getIdentityFundingSources' +import { GetIdentityFundingOperationsHandler } from './private/identities/getIdentityFundingOperations' +import { CancelIdentityFundingHandler } from './private/identities/cancelIdentityFunding' +import { RegisterIdentityFromCoreHandler } from './private/identities/registerIdentityFromCore' +import { TopUpIdentityFromCoreHandler } from './private/identities/topUpIdentityFromCore' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' import { RegisterIdentityHandler } from './private/identities/registerIdentity' @@ -89,6 +97,8 @@ export class PrivateAPI { [key: string]: APIHandler } + identityFunding: IdentityFundingService + async handleMessage (data: EventData): Promise { const { method, payload } = data @@ -104,9 +114,30 @@ export class PrivateAPI { throw new Error(`Invalid payload: ${validation}`) } + if (canConflictWithFunding(method)) { + await this.assertNoConflictingFunding(method) + } + return await handler.handle(data) } + // Refuses a request that would draw on what a pending identity funding + // operation of the selected wallet has reserved. + private async assertNoConflictingFunding (method: string): Promise { + const wallet = await this.identityFunding.walletRepository.getCurrent() + + if (wallet == null) { + return + } + + const operations = await this.identityFunding.repository({ walletId: wallet.walletId, network: wallet.network }).getAll() + const conflicting = findConflictingFunding(method, operations) + + if (conflicting != null) { + throw new Error(`Resume or cancel the pending ${conflicting.source} identity funding operation first`) + } + } + /** * Builds the handler table. Transport is deliberately NOT registered here — * the hosting context owns it (see src/offscreen/index.ts), so the same @@ -122,6 +153,8 @@ export class PrivateAPI { const assetLockFundingAddressesRepository = new AssetLockFundingAddressesRepository(this.storageAdapter) const walletSettingsRepository = new WalletSettingsRepository(this.storageAdapter) const coreExplorer = new CoreExplorerService() + const identityFunding = new IdentityFundingService(walletRepository, this.sdk, this.coreSDK, coreExplorer) + this.identityFunding = identityFunding this.handlers = { [MessagingMethods.GET_STATUS]: new GetStatusHandler(this.storageAdapter, walletRepository), @@ -177,6 +210,12 @@ export class PrivateAPI { [MessagingMethods.GET_CORE_RECEIVE_ADDRESS]: new GetCoreReceiveAddressHandler(walletRepository, coreExplorer, this.sdk), [MessagingMethods.LIST_CORE_ADDRESSES]: new ListCoreAddressesHandler(walletRepository, coreExplorer, this.sdk), [MessagingMethods.GET_CORE_BALANCE]: new GetCoreBalanceHandler(walletRepository, coreExplorer), + [MessagingMethods.PREPARE_IDENTITY_FUNDING]: new PrepareIdentityFundingHandler(walletRepository, identityFunding), + [MessagingMethods.GET_IDENTITY_FUNDING_SOURCES]: new GetIdentityFundingSourcesHandler(walletRepository, identityFunding), + [MessagingMethods.GET_IDENTITY_FUNDING_OPERATIONS]: new GetIdentityFundingOperationsHandler(identityFunding), + [MessagingMethods.CANCEL_IDENTITY_FUNDING]: new CancelIdentityFundingHandler(identityFunding), + [MessagingMethods.REGISTER_IDENTITY_FROM_CORE]: new RegisterIdentityFromCoreHandler(walletRepository, identityFunding), + [MessagingMethods.TOP_UP_IDENTITY_FROM_CORE]: new TopUpIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.GENERATE_PLATFORM_ADDRESSES]: new GeneratePlatformAddressesHandler(walletRepository, this.sdk), [MessagingMethods.LIST_PLATFORM_ADDRESSES]: new ListPlatformAddressesHandler(walletRepository, this.sdk), [MessagingMethods.GET_PLATFORM_ADDRESSES_INFOS]: new GetPlatformAddressesInfosHandler(this.sdk), diff --git a/src/content-script/api/fundingConflicts.ts b/src/content-script/api/fundingConflicts.ts new file mode 100644 index 00000000..a2d6a5e4 --- /dev/null +++ b/src/content-script/api/fundingConflicts.ts @@ -0,0 +1,25 @@ +import { MessagingMethods } from '../../types/enums/MessagingMethods' +import { IdentityFundingOperation } from '../../types/IdentityFundingOperation' +import { isPendingFundingOperation } from '../repository/IdentityFundingRepository' + +// Requests that draw on what a pending identity funding operation has reserved. +// A legacy registration picks its identity index the same way a native one does, +// so while a native registration is pending it would take the same index. +const CONFLICTS: Partial boolean>> = { + [MessagingMethods.REGISTER_IDENTITY]: operation => operation.kind === 'registration' +} + +// Whether a method can conflict with any funding operation at all, so the +// journal is read only when it matters. +export const canConflictWithFunding = (method: string): boolean => CONFLICTS[method] != null + +// The pending operation a request would conflict with, if any. +export const findConflictingFunding = (method: string, operations: IdentityFundingOperation[]): IdentityFundingOperation | undefined => { + const conflicts = CONFLICTS[method] + + if (conflicts == null) { + return undefined + } + + return operations.find(operation => isPendingFundingOperation(operation) && conflicts(operation)) +} diff --git a/src/content-script/api/private/identities/cancelIdentityFunding.ts b/src/content-script/api/private/identities/cancelIdentityFunding.ts new file mode 100644 index 00000000..da0cc3b3 --- /dev/null +++ b/src/content-script/api/private/identities/cancelIdentityFunding.ts @@ -0,0 +1,47 @@ +import { APIHandler } from '../../APIHandler' +import { EventData } from '../../../../types/EventData' +import { RepositoryScope } from '../../../../types/RepositoryScope' +import { IdentityFundingService } from '../../../services/IdentityFundingService' +import { validateFundingScope } from './identityFundingPayload' + +type CancelIdentityFundingPayload = RepositoryScope & { operationId: string } + +// Cancels a quote that was never sent, releasing its funds. Its reserved key and +// change indexes are not reused: they only ever move forward. +export class CancelIdentityFundingHandler implements APIHandler { + service: IdentityFundingService + + constructor (service: IdentityFundingService) { + this.service = service + } + + async handle (event: EventData): Promise<{ cancelled: boolean }> { + const payload: CancelIdentityFundingPayload = event.payload + const repository = this.service.repository(payload) + + await repository.withLock(async () => { + const operation = await repository.get(payload.operationId) + + if (operation == null || operation.status !== 'prepared') { + throw new Error('Only an unsubmitted quote can be cancelled') + } + + await repository.save({ ...operation, status: 'cancelled' }) + }) + + return { cancelled: true } + } + + validatePayload (payload: CancelIdentityFundingPayload): string | null { + const scopeError = validateFundingScope(payload) + + if (scopeError != null) { + return scopeError + } + if (typeof payload.operationId !== 'string') { + return 'Operation id must be provided' + } + + return null + } +} diff --git a/src/content-script/api/private/identities/executeIdentityFunding.ts b/src/content-script/api/private/identities/executeIdentityFunding.ts new file mode 100644 index 00000000..1aa38794 --- /dev/null +++ b/src/content-script/api/private/identities/executeIdentityFunding.ts @@ -0,0 +1,275 @@ +import { Transaction } from 'dash-core-sdk' +import { StateTransitionWASM } from 'dash-platform-sdk/types' +import { APIHandler } from '../../APIHandler' +import { EventData } from '../../../../types/EventData' +import { IdentityFundingOperation, IdentityFundingSource } from '../../../../types/IdentityFundingOperation' +import { ExecuteIdentityFundingPayload } from '../../../../types/messages/payloads/ExecuteIdentityFundingPayload' +import { Wallet } from '../../../../types/Wallet' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingRepository } from '../../../repository/IdentityFundingRepository' +import { IdentityFundingClients, IdentityFundingService } from '../../../services/IdentityFundingService' +import { decryptMnemonic } from '../../../../utils' +import { txidToFilterBytes } from '../../../../utils/txidToFilterBytes' +import { waitForAssetLockProof } from '../../../../utils/waitForAssetLockProof' +import { isOutcomeUnknownError } from '../../../../utils/identityFundingErrors' +import { fundingResponse, validateFundingScope } from './identityFundingPayload' + +// Confirms a prepared identity funding operation, or resumes one that was cut off. +// Every step sends bytes saved in the journal, so a retry never reselects coins. +// It broadcasts the asset lock, waits for its lock proof, builds the identity +// transition that spends it, then sends that and waits for Platform to confirm +// it. The subclasses fix which source and kind each API method drives. +export class ExecuteIdentityFundingHandler implements APIHandler { + walletRepository: WalletRepository + service: IdentityFundingService + source: IdentityFundingSource + kind: IdentityFundingOperation['kind'] + + constructor (walletRepository: WalletRepository, service: IdentityFundingService, source: IdentityFundingSource, kind: IdentityFundingOperation['kind']) { + this.walletRepository = walletRepository + this.service = service + this.source = source + this.kind = kind + } + + async handle (event: EventData): Promise { + const payload: ExecuteIdentityFundingPayload = event.payload + const repository = this.service.repository(payload) + + // Only one document drives an operation at a time. The journal lock is taken + // just for each write, so a long wait here (lock proof, Platform result) + // never holds up other operations or spends of this wallet. + return await repository.withOperationLock(payload.operationId, async () => { + const operation = await repository.get(payload.operationId) + + if (operation == null || operation.source !== this.source || operation.kind !== this.kind) { + throw new Error('Funding operation does not match this request') + } + + const walletRepository = this.walletRepository.forScope(payload) + const wallet = await walletRepository.getCurrent() + + if (wallet == null || wallet.type !== 'seedphrase') { + throw new Error('Funding wallet is unavailable') + } + + decryptMnemonic(wallet, payload.password) + + if (operation.status === 'completed') { + return fundingResponse(operation) + } + if (operation.status === 'cancelled' || operation.status === 'failed') { + throw new Error(`Funding operation was ${operation.status}`) + } + + const run = new FundingRun(repository, operation) + + try { + await this.execute(run, walletRepository, wallet, payload.password, this.service.clientsFor(payload)) + } catch (error) { + await run.fail(error) + throw error + } + + return fundingResponse(run.operation) + }) + } + + private async execute (run: FundingRun, walletRepository: WalletRepository, wallet: Wallet, password: string, clients: IdentityFundingClients): Promise { + const { sdk } = clients + + if (run.operation.stateTransition == null) { + await this.fundAssetLock(run, wallet, password, clients) + } + + const saved = run.operation.stateTransition + + if (saved == null) { + throw new Error('Missing saved Platform transition') + } + + const transition = StateTransitionWASM.fromHex(saved) + + if (transition.hash(false) !== run.operation.stateTransitionHash) { + throw new Error('Saved Platform transition hash mismatch') + } + + if (run.operation.status !== 'confirmed') { + await run.claim('platformBroadcast') + await this.service.broadcastTransition(sdk, transition) + // Verifies the GroveDB proof and the quorum signature, and throws when the + // transition failed to execute. + await sdk.stateTransitions.waitForStateTransitionResult(transition) + await run.update({ status: 'confirmed' }) + } + + if (run.operation.kind === 'registration') { + const identityId = await this.service.saveRegisteredIdentity(run.operation, walletRepository, wallet, password, sdk) + + if (identityId == null) { + throw new Error('Confirmed registration has no identity yet; retry to resolve it') + } + + await run.update({ identityId }) + } + + await run.update({ status: 'completed', error: undefined }) + } + + // Broadcasts the saved asset lock, waits for its InstantLock or ChainLock proof + // and saves the identity transition that spends it. + private async fundAssetLock (run: FundingRun, wallet: Wallet, password: string, clients: IdentityFundingClients): Promise { + const { sdk, core } = clients + const { coreTransaction, assetLockTxid } = run.operation + + if (coreTransaction == null || assetLockTxid == null) { + throw new Error('Missing saved Core transaction') + } + + const tx = Transaction.fromHex(coreTransaction) + + if (tx.hash() !== assetLockTxid) { + throw new Error('Saved Core transaction hash mismatch') + } + + // Once the network took the asset lock the operation is 'proving' for good: + // a retry never broadcasts it again, so a flaky node cannot make a lock that + // is already on L1 look refused. + const broadcast = run.operation.status !== 'proving' + // A recovering operation needs a ChainLock proof; with no InstantLock stream + // to listen to, the wait falls through to it. + const subscription = run.operation.chainLockProofOnly === true + ? noInstantLocks() + : core.subscribeToTransactions([], [txidToFilterBytes(assetLockTxid)]) + + if (broadcast) { + // Persist intent BEFORE any network write. Cancellation is no longer safe. + await run.claim('coreBroadcast') + } + + const controller = new AbortController() + // Consume the lazy stream before broadcast so a fast InstantLock is not + // missed; the proof is stored only after the broadcast. + const savedProof = run.operation.assetLockProof + const proofPromise = savedProof != null + ? Promise.resolve(savedProof) + : waitForAssetLockProof(core, sdk, tx, assetLockTxid, subscription, undefined, undefined, controller.signal) + void proofPromise.catch(() => {}) + + try { + if (broadcast) { + await this.service.broadcastAssetLock(tx, core) + await run.update({ status: 'proving' }) + } + + if (savedProof == null) { + await run.update({ assetLockProof: await proofPromise }) + } + } finally { + controller.abort() + } + + const proof = run.operation.assetLockProof + + if (proof == null) { + throw new Error('Missing asset lock proof') + } + + const transition = await this.service.buildAssetLockTransition(run.operation, proof, wallet, password, sdk) + await run.update({ stateTransition: transition.hex(), stateTransitionHash: transition.hash(false) }) + } + + validatePayload (payload: ExecuteIdentityFundingPayload): string | null { + const scopeError = validateFundingScope(payload) + + if (scopeError != null) { + return scopeError + } + if (typeof payload.operationId !== 'string' || payload.operationId.length === 0) { + return 'Operation id must be provided' + } + if (typeof payload.password !== 'string' || payload.password.length === 0) { + return 'Password must be provided' + } + + return null + } +} + +// An InstantLock stream that never yields, for a wait that must end on a ChainLock. +const noInstantLocks = (): ReturnType => { + return { async * [Symbol.asyncIterator] () {} } as any +} + +// One execution of an operation: its current state and the journal writes that +// move it forward. Each write takes the journal lock for itself only. +class FundingRun { + repository: IdentityFundingRepository + operation: IdentityFundingOperation + + constructor (repository: IdentityFundingRepository, operation: IdentityFundingOperation) { + this.repository = repository + this.operation = operation + } + + // A field set to undefined is removed, so the journal never stores it. + async update (changes: Partial): Promise { + const merged = Object.entries({ ...this.operation, ...changes }).filter(([, value]) => value !== undefined) + this.operation = Object.fromEntries(merged) as unknown as IdentityFundingOperation + + await this.repository.withLock(async () => { await this.repository.save(this.operation) }) + } + + // Moves to a network-writing status unless a concurrent cancel got there first: + // both run under the journal lock. + async claim (status: IdentityFundingOperation['status']): Promise { + this.operation = await this.repository.withLock(async () => { + const stored = await this.repository.get(this.operation.id) + + if (stored == null || stored.status === 'cancelled') { + throw new Error('Funding operation was cancelled') + } + + const next = { ...this.operation, status } + await this.repository.save(next) + + return next + }) + } + + // Records the error. An unknown outcome may still execute and stays pending as + // is. A Core transaction the network refused spent nothing and releases the + // reservation; a transition rejected on an asset lock keeps the lock for a + // fresh attempt. + async fail (error: unknown): Promise { + const message = error instanceof Error ? error.message : String(error) + + if (isOutcomeUnknownError(error)) { + await this.update({ error: message }) + } else if (this.isReleasable()) { + await this.update({ error: message, status: 'failed' }) + } else if (this.operation.status === 'platformBroadcast') { + // The asset lock is on L1 and this transition did not spend it. Drop the + // transition and its proof: a retry waits for a ChainLock proof of the same + // lock and signs afresh, since an InstantLock proof expires. The credit + // output is bound to the reserved key index, so the index stays. + await this.update({ + error: message, + status: 'proving', + chainLockProofOnly: true, + assetLockProof: undefined, + stateTransition: undefined, + stateTransitionHash: undefined + }) + } else { + await this.update({ error: message }) + } + } + + // A Core transaction the network refused never left the wallet, so its coins + // are free again. A Core asset lock already on L1 is different: its funds sit + // in the lock, and the operation stays pending to finish on the same lock. + private isReleasable (): boolean { + return this.operation.status === 'coreBroadcast' + } +} diff --git a/src/content-script/api/private/identities/getIdentityFundingOperations.ts b/src/content-script/api/private/identities/getIdentityFundingOperations.ts new file mode 100644 index 00000000..536aeea6 --- /dev/null +++ b/src/content-script/api/private/identities/getIdentityFundingOperations.ts @@ -0,0 +1,46 @@ +import { APIHandler } from '../../APIHandler' +import { EventData } from '../../../../types/EventData' +import { RepositoryScope } from '../../../../types/RepositoryScope' +import { IdentityFundingOperation } from '../../../../types/IdentityFundingOperation' +import { IdentityFundingService } from '../../../services/IdentityFundingService' +import { AssetLockFundingAddressesRepository } from '../../../repository/AssetLockFundingAddressesRepository' +import { fundingResponse, validateFundingScope } from './identityFundingPayload' + +interface LegacyFundingEntry { + address: string + purpose: string + identityId?: string + assetLockTxid?: string +} + +// Lists the wallet's funding operations, and the unfinished legacy deposits made +// to a funding address before native funding, which can still be resumed. +export class GetIdentityFundingOperationsHandler implements APIHandler { + service: IdentityFundingService + + constructor (service: IdentityFundingService) { + this.service = service + } + + async handle (event: EventData): Promise<{ operations: IdentityFundingOperation[], legacy: LegacyFundingEntry[] }> { + const payload: RepositoryScope = event.payload + const repository = this.service.repository(payload) + const legacy = await new AssetLockFundingAddressesRepository(repository.storageAdapter, payload).getAll() + + return { + operations: (await repository.getAll()).map(fundingResponse), + legacy: legacy + .filter(entry => !entry.used) + .map(entry => ({ + address: entry.address, + purpose: entry.purpose ?? 'registration', + identityId: entry.identityId, + assetLockTxid: entry.assetLockTxid ?? undefined + })) + } + } + + validatePayload (payload: RepositoryScope): string | null { + return validateFundingScope(payload) + } +} diff --git a/src/content-script/api/private/identities/getIdentityFundingSources.ts b/src/content-script/api/private/identities/getIdentityFundingSources.ts new file mode 100644 index 00000000..d127f732 --- /dev/null +++ b/src/content-script/api/private/identities/getIdentityFundingSources.ts @@ -0,0 +1,41 @@ +import { APIHandler } from '../../APIHandler' +import { EventData } from '../../../../types/EventData' +import { RepositoryScope } from '../../../../types/RepositoryScope' +import { GetIdentityFundingSourcesResponse } from '../../../../types/messages/response/GetIdentityFundingSourcesResponse' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' +import { validateFundingScope } from './identityFundingPayload' + +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) + +// What the wallet can put towards an identity: its Core balance, read by account +// xpub. A balance that cannot be read reports its error instead. +export class GetIdentityFundingSourcesHandler implements APIHandler { + walletRepository: WalletRepository + service: IdentityFundingService + + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + this.walletRepository = walletRepository + this.service = service + } + + async handle (event: EventData): Promise { + const payload: RepositoryScope = event.payload + const walletRepository = this.walletRepository.forScope(payload) + const wallet = await walletRepository.getCurrent() + + if (wallet == null || wallet.type !== 'seedphrase') { + throw new Error('Native funding requires a seedphrase wallet') + } + + try { + return { core: { balanceCredits: await this.service.coreBalanceCredits(walletRepository, wallet.network) } } + } catch (error) { + return { core: { error: errorMessage(error) } } + } + } + + validatePayload (payload: RepositoryScope): string | null { + return validateFundingScope(payload) + } +} diff --git a/src/content-script/api/private/identities/identityFundingPayload.ts b/src/content-script/api/private/identities/identityFundingPayload.ts new file mode 100644 index 00000000..a5d79c0f --- /dev/null +++ b/src/content-script/api/private/identities/identityFundingPayload.ts @@ -0,0 +1,23 @@ +import { RepositoryScope } from '../../../../types/RepositoryScope' +import { IdentityFundingOperation } from '../../../../types/IdentityFundingOperation' + +// Funding requests always name the wallet and network they run against, so a +// switch of the selected wallet can never retarget an operation. +export const validateFundingScope = (payload: RepositoryScope): string | null => { + if (payload == null || typeof payload.walletId !== 'string' || payload.walletId.length === 0) { + return 'walletId must be provided' + } + if (payload.network !== 'mainnet' && payload.network !== 'testnet') { + return 'network must be mainnet or testnet' + } + + return null +} + +// Signed bytes stay in the backend until confirmation; cancelling a quote must +// not leave a broadcastable transaction in the caller's hands. +export const fundingResponse = (operation: IdentityFundingOperation): IdentityFundingOperation => { + const { coreTransaction, stateTransition, assetLockProof, ...response } = operation + + return response +} diff --git a/src/content-script/api/private/identities/prepareIdentityFunding.ts b/src/content-script/api/private/identities/prepareIdentityFunding.ts new file mode 100644 index 00000000..109bc1a1 --- /dev/null +++ b/src/content-script/api/private/identities/prepareIdentityFunding.ts @@ -0,0 +1,211 @@ +import { APIHandler } from '../../APIHandler' +import { EventData } from '../../../../types/EventData' +import { IdentityFundingOperation } from '../../../../types/IdentityFundingOperation' +import { PrepareIdentityFundingPayload } from '../../../../types/messages/payloads/PrepareIdentityFundingPayload' +import { Wallet } from '../../../../types/Wallet' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentitiesRepository } from '../../../repository/IdentitiesRepository' +import { AssetLockFundingAddressesRepository } from '../../../repository/AssetLockFundingAddressesRepository' +import { isPendingFundingOperation } from '../../../repository/IdentityFundingRepository' +import { IdentityFundingClients, IdentityFundingService } from '../../../services/IdentityFundingService' +import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' +import { decryptMnemonic, validateIdentifier } from '../../../../utils' +import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' +import { fundingResponse, validateFundingScope } from './identityFundingPayload' + +const CREDITS_PER_DUFF = 1000n + +// Quotes an identity registration or top-up paid from the wallet's own Core +// coins, and saves it to the journal with the asset lock signed, so confirming it +// never reselects coins. The same operation id returns the saved quote; a pending +// Core operation (or another registration) has to be resumed or cancelled first. +export class PrepareIdentityFundingHandler implements APIHandler { + walletRepository: WalletRepository + service: IdentityFundingService + + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + this.walletRepository = walletRepository + this.service = service + } + + async handle (event: EventData): Promise { + const payload: PrepareIdentityFundingPayload = event.payload + const repository = this.service.repository(payload) + + // Indexes, coins and the Platform nonce are reserved under the journal lock. + return await repository.withLock(async () => { + const walletRepository = this.walletRepository.forScope(payload) + const wallet = await walletRepository.getCurrent() + + if (wallet == null || wallet.type !== 'seedphrase') { + throw new Error('Select a seedphrase wallet on this network') + } + + // Validates the password even when a saved quote is returned. + decryptMnemonic(wallet, payload.password) + + const previous = await repository.get(payload.operationId) + + if (previous != null) { + if (!this.isSameRequest(previous, payload)) { + throw new Error('Operation id is already used for different funding parameters') + } + + return fundingResponse(previous) + } + + const operations = await repository.getAll() + // One pending operation per source: two would select the same coins. + // Registrations also share the identity index sequence. + const conflicting = operations.find(op => isPendingFundingOperation(op) && + (op.source === payload.source || (op.kind === 'registration' && payload.kind === 'registration'))) + + if (conflicting != null) { + throw new Error(`Resume or cancel the pending ${conflicting.source} funding operation first`) + } + + const clients = this.service.clientsFor(payload) + const legacy = await new AssetLockFundingAddressesRepository(repository.storageAdapter, payload).getAll() + + // A legacy asset lock broadcast before its index was pinned could have funded + // any index: allocating another now could take the same one. + if (legacy.some(entry => !entry.used && (entry.purpose ?? 'registration') === 'registration' && entry.assetLockTxid != null && entry.registrationIdentityIndex == null)) { + throw new Error('Resume the legacy asset lock with an unknown identity index before allocating another identity') + } + + const operation: IdentityFundingOperation = { + id: payload.operationId, + walletId: payload.walletId, + network: payload.network, + account: 0, + kind: payload.kind, + source: payload.source, + amountCredits: payload.amountCredits, + identityId: payload.identityId, + status: 'prepared', + createdAt: Date.now() + } + + if (operation.kind === 'registration') { + const identities = await new IdentitiesRepository(repository.storageAdapter, clients.sdk, payload).getAll() + const taken = [ + ...identities.map(identity => identity.index), + ...operations.map(op => op.identityIndex), + ...legacy.map(entry => entry.registrationIdentityIndex) + ].filter((index): index is number => index != null) + + operation.identityIndex = await this.service.reserveIdentityIndex(wallet, payload.password, clients.sdk, taken) + } + + await this.quoteCore(operation, operations, legacy, walletRepository, wallet, payload.password, clients) + + const feeCredits = BigInt(operation.feeCredits ?? '0') + + if (BigInt(operation.amountCredits) <= feeCredits) { + throw new Error('Funding amount must exceed the estimated Platform fee') + } + + operation.estimatedNetCredits = (BigInt(operation.amountCredits) - feeCredits).toString() + await repository.save(operation) + + return fundingResponse(operation) + }) + } + + // Selects and verifies the account's coins, signs the asset lock and sizes the + // Platform fee of the transition it will fund. Nothing is broadcast here. + private async quoteCore ( + operation: IdentityFundingOperation, + operations: IdentityFundingOperation[], + legacy: AssetLockFundingAddressSchema[], + walletRepository: WalletRepository, + wallet: Wallet, + password: string, + clients: IdentityFundingClients + ): Promise { + const { sdk, core } = clients + const amountCredits = BigInt(operation.amountCredits) + + if (amountCredits % CREDITS_PER_DUFF !== 0n) { + throw new Error('Core amount must be a whole number of duffs (1000 credits)') + } + + if (operation.kind === 'topUp') { + const taken = [...operations.map(op => op.topUpIndex), ...legacy.map(entry => entry.index)] + .filter((index): index is number => index != null) + + operation.topUpIndex = await this.service.reserveTopUpIndex(wallet, password, sdk, taken) + } + + const creditKey = await this.service.creditKey(operation, wallet, password, sdk) + const creditOutputAddress = sdk.keyPair.p2pkhAddress(creditKey.getPublicKey().bytes(), wallet.network as any) + const xpub = await this.service.coreAccountXpub(walletRepository, wallet, password, sdk) + const { utxos, nextUnusedChange } = await this.service.loadSpendableUtxos(xpub, wallet, sdk) + + operation.changeIndex = Math.max(nextUnusedChange, ...operations.map(op => (op.changeIndex ?? -1) + 1)) + + // Completed operations keep their outpoints: an indexer may still report a + // spent output. A cancelled or failed operation never spent its inputs. + const reserved = new Set(operations + .filter(op => op.status !== 'cancelled' && op.status !== 'failed') + .flatMap(op => op.corePlan?.inputs.map(input => `${input.txid}:${input.vout}`) ?? [])) + + const plan = selectAssetLockUtxos( + utxos, + amountCredits / CREDITS_PER_DUFF, + creditOutputAddress, + this.service.changeAddress(xpub, wallet, sdk, operation.changeIndex), + reserved + ) + + await this.service.verifyUtxoParents(plan.inputs, core) + + const tx = await this.service.signAssetLock(plan, wallet, password, sdk) + + operation.corePlan = plan + operation.coreTransaction = tx.hex() + operation.assetLockTxid = tx.hash() + operation.feeCredits = (await this.service.estimateAssetLockTransitionFee(operation, tx.hash(), wallet, password, sdk)).toString() + operation.balanceCredits = (utxos + .filter(input => !reserved.has(`${input.txid}:${input.vout}`)) + .reduce((sum, input) => sum + BigInt(input.amount), 0n) * CREDITS_PER_DUFF).toString() + } + + private isSameRequest (previous: IdentityFundingOperation, payload: PrepareIdentityFundingPayload): boolean { + return previous.kind === payload.kind && + previous.source === payload.source && + previous.amountCredits === payload.amountCredits && + (payload.kind !== 'topUp' || previous.identityId === payload.identityId) + } + + validatePayload (payload: PrepareIdentityFundingPayload): string | null { + const scopeError = validateFundingScope(payload) + + if (scopeError != null) { + return scopeError + } + if (typeof payload.operationId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(payload.operationId)) { + return 'Invalid operation id' + } + if (payload.source !== 'core') { + return 'Invalid funding source' + } + if (payload.kind !== 'registration' && payload.kind !== 'topUp') { + return 'Invalid funding operation' + } + if (typeof payload.password !== 'string' || payload.password.length === 0) { + return 'Password must be provided' + } + if (typeof payload.amountCredits !== 'string' || !/^[1-9]\d{0,18}$/.test(payload.amountCredits)) { + return 'Amount must be a positive integer string of credits' + } + if (payload.kind === 'topUp' && !validateIdentifier(payload.identityId ?? '')) { + return 'Invalid target identity' + } + if (payload.kind === 'registration' && payload.identityId != null) { + return 'Registration cannot specify a target identity' + } + + return null + } +} diff --git a/src/content-script/api/private/identities/registerIdentityFromCore.ts b/src/content-script/api/private/identities/registerIdentityFromCore.ts new file mode 100644 index 00000000..ba48fd5f --- /dev/null +++ b/src/content-script/api/private/identities/registerIdentityFromCore.ts @@ -0,0 +1,9 @@ +import { ExecuteIdentityFundingHandler } from './executeIdentityFunding' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' + +export class RegisterIdentityFromCoreHandler extends ExecuteIdentityFundingHandler { + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + super(walletRepository, service, 'core', 'registration') + } +} diff --git a/src/content-script/api/private/identities/topUpIdentityFromCore.ts b/src/content-script/api/private/identities/topUpIdentityFromCore.ts new file mode 100644 index 00000000..5c7d689a --- /dev/null +++ b/src/content-script/api/private/identities/topUpIdentityFromCore.ts @@ -0,0 +1,9 @@ +import { ExecuteIdentityFundingHandler } from './executeIdentityFunding' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' + +export class TopUpIdentityFromCoreHandler extends ExecuteIdentityFundingHandler { + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + super(walletRepository, service, 'core', 'topUp') + } +} diff --git a/src/content-script/migrations/0010_identity_funding.ts b/src/content-script/migrations/0010_identity_funding.ts new file mode 100644 index 00000000..684a09c5 --- /dev/null +++ b/src/content-script/migrations/0010_identity_funding.ts @@ -0,0 +1,7 @@ +import { StorageAdapter } from '../storage/storageAdapter' + +// identityFunding__ is created lazily. Existing legacy +// deposits and all derivation counters must survive this migration unchanged. +export default async function addIdentityFunding (storageAdapter: StorageAdapter): Promise { + if (await storageAdapter.get('schema_version') === 9) await storageAdapter.set('schema_version', 10) +} diff --git a/src/content-script/repository/AssetLockFundingAddressesRepository.ts b/src/content-script/repository/AssetLockFundingAddressesRepository.ts index 3a82c7c1..488595f0 100644 --- a/src/content-script/repository/AssetLockFundingAddressesRepository.ts +++ b/src/content-script/repository/AssetLockFundingAddressesRepository.ts @@ -110,6 +110,10 @@ export class AssetLockFundingAddressesRepository { return addresses[address] ?? null } + async getAll (): Promise { + return Object.values(await this.storageAdapter.get(await this.getStorageKey()) ?? {}) as AssetLockFundingAddressSchema[] + } + // `identityId` narrows the result to addresses reserved for that identity. // 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 diff --git a/src/content-script/repository/IdentityFundingRepository.ts b/src/content-script/repository/IdentityFundingRepository.ts new file mode 100644 index 00000000..0f1f724c --- /dev/null +++ b/src/content-script/repository/IdentityFundingRepository.ts @@ -0,0 +1,53 @@ +import { StorageAdapter } from '../storage/storageAdapter' +import { RepositoryScope } from '../../types/RepositoryScope' +import { IdentityFundingOperation } from '../../types/IdentityFundingOperation' + +const FINISHED_STATUSES: Array = ['completed', 'cancelled', 'failed'] + +// A pending operation still holds its funds: coins, a Platform nonce or notes. +export const isPendingFundingOperation = (operation: IdentityFundingOperation): boolean => { + return !FINISHED_STATUSES.includes(operation.status) +} + +export class IdentityFundingRepository { + constructor (public storageAdapter: StorageAdapter, public scope: RepositoryScope) {} + + async getAll (): Promise { + return Object.values(await this.storageAdapter.get(this.storageKey()) ?? {}) as IdentityFundingOperation[] + } + + async get (id: string): Promise { + return (await this.getAll()).find(operation => operation.id === id) + } + + // Call under withLock: storage adapters have no atomic compare-and-swap. + async save (operation: IdentityFundingOperation): Promise { + if (operation.network !== this.scope.network || operation.walletId !== this.scope.walletId) throw new Error('Funding operation scope mismatch') + const entries = await this.getAll() + await this.storageAdapter.set(this.storageKey(), Object.fromEntries([ + ...entries.map(entry => [entry.id, entry]), [operation.id, operation] + ])) + } + + async withLock (callback: () => Promise): Promise { + // Web Locks coordinate all extension documents, including old popup API + // hosts. A lock is released on document termination; the journal survives. + if (typeof navigator === 'undefined' || navigator.locks == null) throw new Error('Wallet funding requires Web Locks support') + return await navigator.locks.request(this.storageKey(), callback) + } + + // Held for a whole execute of one operation, so two documents never drive the + // same operation at once. The journal lock above stays short: a long wait here + // must not hold up other operations or spends of this wallet. + async withOperationLock (id: string, callback: () => Promise): Promise { + if (typeof navigator === 'undefined' || navigator.locks == null) { + throw new Error('Wallet funding requires Web Locks support') + } + + return await navigator.locks.request(`${this.storageKey()}:${id}`, callback) + } + + private storageKey (): string { + return `identityFunding_${this.scope.network}_${this.scope.walletId}` + } +} diff --git a/src/content-script/repository/WalletRepository.ts b/src/content-script/repository/WalletRepository.ts index 9c17009e..ccda78c2 100644 --- a/src/content-script/repository/WalletRepository.ts +++ b/src/content-script/repository/WalletRepository.ts @@ -243,7 +243,7 @@ export class WalletRepository { } async setCoreAccountXpub (account: number, xpub: string): Promise { - const network = await this.storageAdapter.get('network') as string + const network = await this.getNetwork() const walletStoreSchema = await this.getCurrentStoreSchema() const storageKey = `wallet_${network}_${walletStoreSchema.walletId}` diff --git a/src/content-script/services/CoreExplorerService.ts b/src/content-script/services/CoreExplorerService.ts index 0a8d4d59..903a7060 100644 --- a/src/content-script/services/CoreExplorerService.ts +++ b/src/content-script/services/CoreExplorerService.ts @@ -26,6 +26,14 @@ export interface CoreAddressUtxo { amount: bigint } +// An account output, with the address that received it. +export interface CoreXpubUtxo extends CoreAddressUtxo { + address: string +} + +// 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 } @@ -115,6 +123,40 @@ export class CoreExplorerService { } } + // Every confirmed output held by an address the xpub derives, in one walk over + // the explorer's pages instead of one request per 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 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 + } + } + } + // An address counts as used once it has appeared in at least one transaction. // Never-seen addresses (404) are free to claim for the gap-scan. async isAddressUsed (address: string, network: NetworkType = 'testnet'): Promise { diff --git a/src/content-script/services/IdentityFundingService.ts b/src/content-script/services/IdentityFundingService.ts new file mode 100644 index 00000000..0c45dd66 --- /dev/null +++ b/src/content-script/services/IdentityFundingService.ts @@ -0,0 +1,337 @@ +import { DashPlatformSDK } from 'dash-platform-sdk' +import { DashCoreSDK, Output, PrivateKey, Transaction } from 'dash-core-sdk' +import { KeyType, Network, PrivateKeyWASM, StateTransitionWASM } from 'dash-platform-sdk/types' +import { WalletRepository } from '../repository/WalletRepository' +import { IdentitiesRepository } from '../repository/IdentitiesRepository' +import { IdentityFundingRepository } from '../repository/IdentityFundingRepository' +import { CoreExplorerService } from './CoreExplorerService' +import { IdentityFundingOperation } from '../../types/IdentityFundingOperation' +import { RepositoryScope } from '../../types/RepositoryScope' +import { Wallet } from '../../types/Wallet' +import { CoreUtxo } from '../../types/CoreUtxo' +import { CoreAddressChain } from '../../types/enums/CoreAddressChain' +import { IdentityType } from '../../types/enums/IdentityType' +import { + deriveWalletHdKey, + deriveIdentityPrivateKey, + deriveIdentityRegistrationKey, + deriveIdentityTopUpKey +} from '../../utils' +import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' +import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' +import { buildIdentityCreateTransition, IDENTITY_KEY_DEFINITIONS } from '../../utils/identityRegistration' +import { isTransitionAlreadyKnownError } from '../../utils/identityFundingErrors' +import { isIdentityNotFoundError } from '../../utils/isIdentityNotFoundError' +import { AssetLockProof } from '../../types/AssetLockProof' +import { IDENTITY_INDEX_SCAN_LIMIT } from '../../constants' + +export interface IdentityFundingClients { + sdk: DashPlatformSDK + core: DashCoreSDK +} + +// Largest address window read from the explorer on one chain. +const MAX_CORE_ADDRESS_WINDOW = 10000 + +// Identity funding primitives: key and index reservation, the Core asset lock +// quote and the network steps of an operation. The handlers compose them into the prepare +// and execute flows; the journal stores signed bytes, never private keys. +export class IdentityFundingService { + walletRepository: WalletRepository + sdk: DashPlatformSDK + coreSDK: DashCoreSDK + explorer: CoreExplorerService + + // Separate SDK instances cannot be repointed by SWITCH_NETWORK mid-operation. + private readonly clients = new Map() + + constructor (walletRepository: WalletRepository, sdk: DashPlatformSDK, coreSDK: DashCoreSDK, explorer: CoreExplorerService) { + this.walletRepository = walletRepository + this.sdk = sdk + this.coreSDK = coreSDK + this.explorer = explorer + } + + repository (scope: RepositoryScope): IdentityFundingRepository { + return new IdentityFundingRepository(this.walletRepository.storageAdapter, scope) + } + + clientsFor (scope: RepositoryScope): IdentityFundingClients { + let clients = this.clients.get(scope.network) + + if (clients == null) { + const options = this.sdk.getNetwork() === scope.network ? this.sdk.options : undefined + clients = { + sdk: new DashPlatformSDK({ ...options, network: scope.network as Network }), + core: this.coreSDK.network === scope.network ? this.coreSDK : new DashCoreSDK({ network: scope.network }) + } + this.clients.set(scope.network, clients) + } + + return clients + } + + // ── Keys and indexes ───────────────────────────────────────────────────────── + + // The first identity index past every one already taken whose auth key is not + // registered on chain, e.g. by the same seed in another install. + async reserveIdentityIndex (wallet: Wallet, password: string, sdk: DashPlatformSDK, taken: number[]): Promise { + const start = Math.max(-1, ...taken) + 1 + + for (let index = start; index < start + IDENTITY_INDEX_SCAN_LIMIT; index++) { + const key = await deriveIdentityPrivateKey(wallet, password, index, 0, sdk) + + if (!(await this.isAuthKeyRegistered(sdk, key.getPublicKeyHash()))) { + return index + } + } + + throw new Error('Could not find a free identity index') + } + + // The first DIP-13 top-up index past every one already taken whose funding + // address the explorer has never seen, so an imported seed skips spent ones. + async reserveTopUpIndex (wallet: Wallet, password: string, sdk: DashPlatformSDK, taken: number[]): Promise { + const start = Math.max(-1, ...taken) + 1 + + for (let index = start; index < start + IDENTITY_INDEX_SCAN_LIMIT; index++) { + const key = await deriveIdentityTopUpKey(wallet, password, index, sdk) + const address = sdk.keyPair.p2pkhAddress(key.getPublicKey().bytes(), wallet.network as Network) + + if (!(await this.explorer.isAddressUsed(address, wallet.network))) { + return index + } + } + + throw new Error('Could not find a free top-up key index') + } + + async identityKeys (operation: IdentityFundingOperation, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + if (operation.identityIndex == null) { + throw new Error('Missing reserved identity index') + } + + const identityIndex = operation.identityIndex + + return await Promise.all(IDENTITY_KEY_DEFINITIONS.map(async key => await deriveIdentityPrivateKey(wallet, password, identityIndex, key.id, sdk))) + } + + // The DIP-13 key that owns the asset lock credit output and signs its transition. + async creditKey (operation: IdentityFundingOperation, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + if (operation.kind === 'registration' && operation.identityIndex != null) { + return await deriveIdentityRegistrationKey(wallet, password, operation.identityIndex, sdk) + } + if (operation.kind === 'topUp' && operation.topUpIndex != null) { + return await deriveIdentityTopUpKey(wallet, password, operation.topUpIndex, sdk) + } + + throw new Error('Missing reserved DIP-13 key index') + } + + // ── Core source ────────────────────────────────────────────────────────────── + + // The account xpub, derived and cached if this wallet has none yet. + async coreAccountXpub (walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const cached = await walletRepository.getCoreAccountXpub(0) + + if (cached != null) { + return cached + } + + const xpub = await deriveCoreAccountXpub(wallet, password, 0, sdk) + await walletRepository.setCoreAccountXpub(0, xpub) + + return xpub + } + + // Every confirmed output the account can sign for, with its derivation, plus + // the explorer's next unused index on the change chain. + async loadSpendableUtxos (xpub: string, wallet: Wallet, sdk: DashPlatformSDK): Promise<{ utxos: CoreUtxo[], nextUnusedChange: number }> { + const summary = await this.explorer.getXpubSummary(xpub, wallet.network) + + // Every used address sits below the explorer's next unused index on its + // chain, so this window holds the key for any output the account owns. + const signable = new Map() + for (const chain of [CoreAddressChain.receiving, CoreAddressChain.change]) { + const count = summary.nextUnused[chain] + 1 + + if (!Number.isSafeInteger(count) || count < 1 || count > MAX_CORE_ADDRESS_WINDOW) { + throw new Error('Invalid Core explorer address range') + } + + for (const entry of deriveCoreAddressesFromXpub(sdk, xpub, wallet.network, 0, chain, count)) { + signable.set(entry.address, entry) + } + } + + const utxos: CoreUtxo[] = [] + for (const output of await this.explorer.getXpubUtxos(xpub, wallet.network)) { + const entry = signable.get(output.address) + + // Outside the derived window this wallet cannot sign it; leave it be. + if (entry != null) { + utxos.push({ ...entry, txid: output.txid, vout: output.vout, amount: output.amount.toString() }) + } + } + + return { utxos, nextUnusedChange: summary.nextUnused.change } + } + + changeAddress (xpub: string, wallet: Wallet, sdk: DashPlatformSDK, index: number): string { + return deriveCoreAddressesFromXpub(sdk, xpub, wallet.network, 0, CoreAddressChain.change, 1, index)[0].address + } + + // Checks each selected input against its raw parent transaction — it must + // exist, pay this address this amount, and be locked or deeply confirmed — + // rather than trusting the explorer's report. Records the lock state found. + async verifyUtxoParents (inputs: CoreUtxo[], core: DashCoreSDK): Promise { + for (const input of inputs) { + const parent = await core.getTransaction(input.txid) + const tx = Transaction.fromBytes(parent.transaction) + const output = tx.outputs[input.vout] + const expectedScript = Output.createP2PKH(0n, input.address).script.hex() + + if (tx.hash() !== input.txid || output == null || output.satoshis !== BigInt(input.amount) || output.script.hex() !== expectedScript) { + throw new Error('Core explorer returned an invalid or foreign UTXO') + } + + const confirmed = Number.isSafeInteger(parent.confirmations) && parent.confirmations >= 6 + + if (!parent.isInstantLocked && !parent.isChainLocked && !confirmed) { + throw new Error('Core input is not sufficiently confirmed') + } + + input.confirmations = parent.confirmations + input.isInstantLocked = parent.isInstantLocked + input.isChainLocked = parent.isChainLocked + } + } + + // Signs the planned asset lock, one derived BIP44 key per input. + async signAssetLock (plan: CoreAssetLockPlan, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const root = deriveWalletHdKey(wallet, password, sdk) + const keys: PrivateKey[] = [] + + for (const input of plan.inputs) { + const child = await sdk.keyPair.derivePath(root, input.derivationPath) + + if (child.privateKey == null) { + throw new Error('Could not derive Core input key') + } + + keys.push(PrivateKey.fromBytes(child.privateKey, wallet.network)) + } + + return buildAssetLockFromUtxos(plan, keys) + } + + // The Platform fee of the transition this asset lock will fund. A mined proof is + // not needed to size it: a placeholder chain proof of the same shape is used and + // never stored. Instant proofs are larger, so the estimate is doubled. + async estimateAssetLockTransitionFee (operation: IdentityFundingOperation, assetLockTxid: string, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const proof: AssetLockProof = { type: 'chainLock', txid: assetLockTxid, outputIndex: 0, coreChainLockedHeight: 1 } + const transition = await this.buildAssetLockTransition(operation, proof, wallet, password, sdk) + + return transition.calculateMinRequiredFee() * 2n + } + + // Broadcasts the asset lock. A lost response is fine when the network already + // holds this exact transaction; any other failure is surfaced. + async broadcastAssetLock (tx: Transaction, core: DashCoreSDK): Promise { + try { + await core.broadcastTransaction(tx.bytes()) + } catch (error) { + const known = await core.getTransaction(tx.hash()).catch(() => null) + + if (known == null || Transaction.fromBytes(known.transaction).hash() !== tx.hash()) { + throw error + } + } + } + + // The identity create or top-up transition spending the asset lock, signed by + // the DIP-13 credit key. + async buildAssetLockTransition (operation: IdentityFundingOperation, proof: AssetLockProof, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const creditKey = await this.creditKey(operation, wallet, password, sdk) + + if (operation.kind === 'registration') { + return buildIdentityCreateTransition(await this.identityKeys(operation, wallet, password, sdk), creditKey, proof, sdk) + } + + const transition = sdk.identities.createStateTransition('topUp', { identityId: operation.identityId as string, assetLockProof: proof }) + transition.signByPrivateKey(creditKey, undefined, KeyType.ECDSA_SECP256K1) + + return transition + } + + // ── Platform result ────────────────────────────────────────────────────────── + + // Sends the saved transition. Resending bytes the network already holds is not + // an error; every other rejection is surfaced as is. + async broadcastTransition (sdk: DashPlatformSDK, transition: StateTransitionWASM): Promise { + try { + await sdk.stateTransitions.broadcast(transition) + } catch (error) { + if (!isTransitionAlreadyKnownError(error)) { + throw error + } + } + } + + // Finds the identity a confirmed registration created and stores it in the + // pinned wallet as the selected identity. Null when Platform holds none. + async saveRegisteredIdentity (operation: IdentityFundingOperation, walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const key = await deriveIdentityPrivateKey(wallet, password, operation.identityIndex as number, 0, sdk) + const identity = await sdk.identities.getIdentityByPublicKeyHash(key.getPublicKeyHash()) + + if (identity == null) { + return null + } + + const identityId = identity.id.base58() + const identities = new IdentitiesRepository(this.walletRepository.storageAdapter, sdk, operation) + + if (await identities.getByIdentifier(identityId) == null) { + await identities.create(identityId, IdentityType.regular, operation.identityIndex as number) + } + + await walletRepository.switchIdentity(identityId) + + return identityId + } + + // ── Funding sources ────────────────────────────────────────────────────────── + + async coreBalanceCredits (walletRepository: WalletRepository, network: Wallet['network']): Promise { + const xpub = await walletRepository.getCoreAccountXpub(0) + + if (xpub == null) { + throw new Error('Core xpub is not initialized; unlock this wallet first') + } + + const summary = await this.explorer.getXpubSummary(xpub, network) + + return (summary.balance * 1000n).toString() + } + + private async isAuthKeyRegistered (sdk: DashPlatformSDK, publicKeyHash: string): Promise { + const lookups = [ + async () => await sdk.identities.getIdentityByPublicKeyHash(publicKeyHash), + async () => await sdk.identities.getIdentityByNonUniquePublicKeyHash(publicKeyHash) + ] + + for (const lookup of lookups) { + try { + if ((await lookup()) != null) { + return true + } + } catch (error) { + if (!isIdentityNotFoundError(error)) { + throw error + } + } + } + + return false + } +} diff --git a/src/content-script/storage/runMigrations.ts b/src/content-script/storage/runMigrations.ts index 99cba771..946e5061 100644 --- a/src/content-script/storage/runMigrations.ts +++ b/src/content-script/storage/runMigrations.ts @@ -7,6 +7,7 @@ import moveCurrentIdentityToWallet from '../migrations/0006_move_current_identit import addIdentityType from '../migrations/0007_add_identity_type' import removeIdentityPublicKey from '../migrations/0008_remove_identity_public_key' import updateStateTransitionRepository from '../migrations/0009_update_state_transition_repository' +import addIdentityFunding from '../migrations/0010_identity_funding' import { StorageAdapter } from './storageAdapter' import { SCHEMA_VERSION } from '../../constants' @@ -20,7 +21,8 @@ const migrations = [ moveCurrentIdentityToWallet, addIdentityType, removeIdentityPublicKey, - updateStateTransitionRepository + updateStateTransitionRepository, + addIdentityFunding ] const restoreBackup = async (backup: object, storageAdapter: StorageAdapter): Promise => { 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/IdentityFundingOperation.ts b/src/types/IdentityFundingOperation.ts new file mode 100644 index 00000000..4aa46e7d --- /dev/null +++ b/src/types/IdentityFundingOperation.ts @@ -0,0 +1,33 @@ +import { RepositoryScope } from './RepositoryScope' +import { CoreAssetLockPlan } from '../utils/buildAssetLockFromUtxos' +import { AssetLockProof } from './AssetLockProof' + +// The wallet funds an identity is paid from. +export type IdentityFundingSource = 'core' + +export interface IdentityFundingOperation extends RepositoryScope { + id: string + kind: 'registration' | 'topUp' + source: IdentityFundingSource + account: number + amountCredits: string + identityId?: string + identityIndex?: number + topUpIndex?: number + changeIndex?: number + corePlan?: CoreAssetLockPlan + coreTransaction?: string + assetLockTxid?: string + assetLockProof?: AssetLockProof + // Set once Platform rejected a transition on this asset lock: the next proof + // must be a ChainLock one, since an InstantLock proof may have expired. + chainLockProofOnly?: boolean + stateTransition?: string + stateTransitionHash?: string + feeCredits?: string + estimatedNetCredits?: string + balanceCredits?: string + status: 'prepared' | 'coreBroadcast' | 'proving' | 'platformBroadcast' | 'confirmed' | 'completed' | 'cancelled' | 'failed' + error?: string + createdAt: number +} diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index c6876214..3e8bc9aa 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -4,6 +4,11 @@ import { EventData } from './EventData' import { NetworkType } from './NetworkType' import { GetCoreAddressesResponse } from './messages/response/GetCoreAddressesResponse' import { GetCoreBalanceResponse } from './messages/response/GetCoreBalanceResponse' +import { RepositoryScope } from './RepositoryScope' +import { IdentityFundingOperation } from './IdentityFundingOperation' +import { PrepareIdentityFundingPayload } from './messages/payloads/PrepareIdentityFundingPayload' +import { ExecuteIdentityFundingPayload } from './messages/payloads/ExecuteIdentityFundingPayload' +import { GetIdentityFundingSourcesResponse } from './messages/response/GetIdentityFundingSourcesResponse' import { InitAccountXpubsPayload } from './messages/payloads/InitAccountXpubsPayload' import { InitAccountXpubsResponse } from './messages/response/InitAccountXpubsResponse' import { MessagingMethods } from './enums/MessagingMethods' @@ -458,6 +463,35 @@ export class PrivateAPIClient { return await this._rpcCall(MessagingMethods.GET_CORE_BALANCE, payload) } + // Quotes an identity registration or top-up paid from the wallet's own Core + // coins. Nothing is sent: confirm it with registerIdentityFromCore or + // topUpIdentityFromCore, or release it with cancelIdentityFunding. + async prepareIdentityFunding (payload: PrepareIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.PREPARE_IDENTITY_FUNDING, payload) + } + + async getIdentityFundingSources (scope: RepositoryScope): Promise { + return await this._rpcCall(MessagingMethods.GET_IDENTITY_FUNDING_SOURCES, scope) + } + + // The wallet's funding operations, and unfinished legacy deposits that can still be resumed. + async getIdentityFundingOperations (scope: RepositoryScope): Promise<{ operations: IdentityFundingOperation[], legacy: Array<{ address: string, purpose: string, identityId?: string, assetLockTxid?: string }> }> { + return await this._rpcCall(MessagingMethods.GET_IDENTITY_FUNDING_OPERATIONS, scope) + } + + async cancelIdentityFunding (scope: RepositoryScope, operationId: string): Promise<{ cancelled: boolean }> { + return await this._rpcCall(MessagingMethods.CANCEL_IDENTITY_FUNDING, { ...scope, operationId }) + } + + // Long timeout: these wait for the asset lock's InstantLock or ChainLock proof. + async registerIdentityFromCore (payload: ExecuteIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.REGISTER_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) + } + + async topUpIdentityFromCore (payload: ExecuteIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) + } + async generatePlatformAddresses (password?: string, count?: number): Promise { const payload: GeneratePlatformAddressesPayload = { password, count } diff --git a/src/types/enums/MessagingMethods.ts b/src/types/enums/MessagingMethods.ts index 2c32cfe4..ca104330 100644 --- a/src/types/enums/MessagingMethods.ts +++ b/src/types/enums/MessagingMethods.ts @@ -40,6 +40,12 @@ export enum MessagingMethods { GET_CORE_RECEIVE_ADDRESS = 'GET_CORE_RECEIVE_ADDRESS', LIST_CORE_ADDRESSES = 'LIST_CORE_ADDRESSES', GET_CORE_BALANCE = 'GET_CORE_BALANCE', + PREPARE_IDENTITY_FUNDING = 'PREPARE_IDENTITY_FUNDING', + GET_IDENTITY_FUNDING_SOURCES = 'GET_IDENTITY_FUNDING_SOURCES', + GET_IDENTITY_FUNDING_OPERATIONS = 'GET_IDENTITY_FUNDING_OPERATIONS', + CANCEL_IDENTITY_FUNDING = 'CANCEL_IDENTITY_FUNDING', + REGISTER_IDENTITY_FROM_CORE = 'REGISTER_IDENTITY_FROM_CORE', + TOP_UP_IDENTITY_FROM_CORE = 'TOP_UP_IDENTITY_FROM_CORE', GENERATE_PLATFORM_ADDRESSES = 'GENERATE_PLATFORM_ADDRESSES', LIST_PLATFORM_ADDRESSES = 'LIST_PLATFORM_ADDRESSES', GET_PLATFORM_ADDRESSES_INFOS = 'GET_PLATFORM_ADDRESSES_INFOS', diff --git a/src/types/messages/payloads/ExecuteIdentityFundingPayload.ts b/src/types/messages/payloads/ExecuteIdentityFundingPayload.ts new file mode 100644 index 00000000..c07b8d26 --- /dev/null +++ b/src/types/messages/payloads/ExecuteIdentityFundingPayload.ts @@ -0,0 +1,6 @@ +import { RepositoryScope } from '../../RepositoryScope' + +export interface ExecuteIdentityFundingPayload extends RepositoryScope { + operationId: string + password: string +} diff --git a/src/types/messages/payloads/PrepareIdentityFundingPayload.ts b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts new file mode 100644 index 00000000..02bdef63 --- /dev/null +++ b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts @@ -0,0 +1,14 @@ +import { RepositoryScope } from '../../RepositoryScope' +import { IdentityFundingSource } from '../../IdentityFundingOperation' + +export interface PrepareIdentityFundingPayload extends RepositoryScope { + // chosen by the caller; preparing again with the same id returns the saved quote + operationId: string + kind: 'registration' | 'topUp' + source: IdentityFundingSource + // credits as a string (bigint does not serialize across messaging) + amountCredits: string + password: string + // the identity to top up; not accepted for a registration + identityId?: string +} diff --git a/src/types/messages/response/GetIdentityFundingSourcesResponse.ts b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts new file mode 100644 index 00000000..186a8c2b --- /dev/null +++ b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts @@ -0,0 +1,4 @@ +export interface GetIdentityFundingSourcesResponse { + // The Core balance in credits (as a string), or why it could not be read. + core: { balanceCredits?: string, error?: string } +} diff --git a/src/utils/buildAssetLockFromUtxos.ts b/src/utils/buildAssetLockFromUtxos.ts new file mode 100644 index 00000000..ca9e6a15 --- /dev/null +++ b/src/utils/buildAssetLockFromUtxos.ts @@ -0,0 +1,83 @@ +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 +const DUST_DUFFS = 546n +export const CORE_ASSET_LOCK_FEE_PER_BYTE = 1n + +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 +} + +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}` + if (!/^[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) { + throw new Error('Invalid Core UTXO') + } + if (seen.has(outpoint)) throw new Error('Duplicate Core outpoint') + seen.add(outpoint) + return !reserved.has(outpoint) + }).sort((a, b) => BigInt(a.amount) === BigInt(b.amount) + ? `${a.txid}:${a.vout}`.localeCompare(`${b.txid}:${b.vout}`) + : BigInt(a.amount) > BigInt(b.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' + const withoutChange = estimateFee(plan, feePerByte) + if (total < amountDuffs + withoutChange) continue + plan.changeDuffs = DUST_DUFFS.toString() + const withChange = estimateFee(plan, feePerByte) + const change = total - amountDuffs - withChange + 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') +} + +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/identityFundingErrors.ts b/src/utils/identityFundingErrors.ts new file mode 100644 index 00000000..2b7fdd84 --- /dev/null +++ b/src/utils/identityFundingErrors.ts @@ -0,0 +1,35 @@ +import { isStateTransitionAlreadyInChainError } from './isStateTransitionAlreadyInChainError' +import { isIdempotentTopUpError } from './isIdempotentTopUpError' + +// gRPC-web percent-encodes error text ("tx%20already%20exists%20in%20cache"), +// so match against the decoded form. +const decodedMessage = (error: unknown): string => { + const message = error instanceof Error ? error.message : String(error ?? '') + + try { + return decodeURIComponent(message) + } catch { + return message + } +} + +// The network already holds this exact transition: resending the saved bytes +// found it in the mempool cache or in a block. Neither a failure nor a +// confirmation — the result still has to be awaited. +export const isTransitionAlreadyKnownError = (error: unknown): boolean => { + const message = decodedMessage(error) + + return message.includes('already exists in cache') || + isStateTransitionAlreadyInChainError(message) || + isIdempotentTopUpError(message) +} + +// Nothing is known about the outcome: the request or the wait for its result was +// cut off, and the transition may still execute. The same bytes must be retried; +// this is never a rejection. +export const isOutcomeUnknownError = (error: unknown): boolean => { + const message = decodedMessage(error).toLowerCase() + + return ['deadline has elapsed', 'timed out', 'timeout', 'fetch failed', 'network error', 'unavailable', 'connection'] + .some(fragment => message.includes(fragment)) +} diff --git a/src/utils/waitForAssetLockProof.ts b/src/utils/waitForAssetLockProof.ts index c0cc1550..07be38ba 100644 --- a/src/utils/waitForAssetLockProof.ts +++ b/src/utils/waitForAssetLockProof.ts @@ -30,13 +30,18 @@ export const waitForAssetLockProof = async ( txid: string, subscription: ReturnType, pollIntervalMs: number = LOCK_POLL_INTERVAL_MS, - timeoutMs: number = LOCK_TIMEOUT_MS + timeoutMs: number = LOCK_TIMEOUT_MS, + signal?: AbortSignal ): Promise => { let settled = false + const iterator = subscription[Symbol.asyncIterator]() // Race 1: instant lock via subscription const instantLockRace = async (): Promise => { - for await (const event of subscription) { + while (true) { + const next = await iterator.next() + if (next.done === true) throw new Error('Instant lock subscription ended without result') + const event = next.value if (settled) return await Promise.reject(new Error('cancelled')) if (event.event !== 'instantSendLockMessage') continue @@ -52,8 +57,6 @@ export const waitForAssetLockProof = async ( return utils.createAssetLockProof({ transaction: assetLockTx, instantLock, outputIndex: 0 }) as InstantAssetLockProofParams } - - return await Promise.reject(new Error('Instant lock subscription ended without result')) } // Race 2: chain lock via polling @@ -108,11 +111,25 @@ export const waitForAssetLockProof = async ( )) } - const result = await Promise.race([ - instantLockRace(), - chainLockRace() - ]) - - settled = true - return result + let onAbort: (() => void) | undefined + const aborted = new Promise((resolve, reject) => { + onAbort = () => { reject(new Error('Asset lock proof wait cancelled')) } + if (signal?.aborted === true) onAbort() + else signal?.addEventListener('abort', onAbort, { once: true }) + }) + try { + return await Promise.race([ + // Subscription failure must not disable chain-lock recovery. The polling + // branch still enforces a finite deadline if no InstantLock arrives. + instantLockRace().catch(async () => await new Promise(() => {})), + chainLockRace(), + aborted + ]) + } finally { + settled = true + if (onAbort != null) signal?.removeEventListener('abort', onAbort) + // Some SDK iterators wait for the next stream event before returning. + // Do not let their shutdown delay a confirmed result or a timeout. + void iterator.return?.().catch(() => {}) + } } diff --git a/test/api/private/identities/identityFunding.spec.ts b/test/api/private/identities/identityFunding.spec.ts new file mode 100644 index 00000000..3df1f788 --- /dev/null +++ b/test/api/private/identities/identityFunding.spec.ts @@ -0,0 +1,225 @@ +import { DashPlatformSDK } from 'dash-platform-sdk' +import { Transaction, Output } from 'dash-core-sdk' +import { PrivateKey, encrypt } from 'eciesjs' +import hash from 'hash.js' +import { IdentityFundingService } from '../../../../src/content-script/services/IdentityFundingService' +import { PrepareIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/prepareIdentityFunding' +import { ExecuteIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/executeIdentityFunding' +import { CancelIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/cancelIdentityFunding' +import { IdentitiesRepository } from '../../../../src/content-script/repository/IdentitiesRepository' +import { WalletRepository } from '../../../../src/content-script/repository/WalletRepository' +import { bytesToHex, utf8ToBytes, derivePlatformAccountXpub, derivePlatformAddressesFromXpub } from '../../../../src/utils' +import { deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../../../src/utils/coreAddresses' +import { CoreAddressChain } from '../../../../src/types/enums/CoreAddressChain' +import { WalletType } from '../../../../src/types/WalletType' +import { PrepareIdentityFundingPayload } from '../../../../src/types/messages/payloads/PrepareIdentityFundingPayload' +import { waitForAssetLockProof } from '../../../../src/utils/waitForAssetLockProof' +import { IsolatedStorage, installWebLocks } from '../../../helpers/isolatedStorage' + +jest.mock('../../../../src/utils/waitForAssetLockProof', () => ({ waitForAssetLockProof: jest.fn() })) +const proofMock = waitForAssetLockProof as jest.Mock +const mnemonic = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const password = 'test-password' +const scope = { walletId: 'wallet1', network: 'testnet' as const } +const identityId = 'HT3pUBM1Uv2mKgdPEN1gxa7A4PdsvNY89aJbdSKQb5wR' + +describe('identity funding handlers', () => { + let storage: IsolatedStorage + let sdk: DashPlatformSDK + let core: any + let explorer: any + let service: IdentityFundingService + let walletRepository: WalletRepository + let restoreLocks: () => void + let request: PrepareIdentityFundingPayload + let platformAddress: string + + beforeAll(() => { restoreLocks = installWebLocks() }) + afterAll(() => { restoreLocks() }) + beforeEach(async () => { + jest.clearAllMocks() + storage = new IsolatedStorage() + sdk = new DashPlatformSDK({ network: 'testnet', grpc: { poolLimit: 5, dapiUrl: 'http://127.0.0.1:1' } }) + const publicKey = PrivateKey.fromHex(hash.sha256().update(password).digest('hex')).publicKey.toHex() + const wallet = { ...scope, type: WalletType.seedphrase, encryptedMnemonic: bytesToHex(encrypt(publicKey, utf8ToBytes(mnemonic))), seedHash: null, label: null, currentIdentity: null } + const xpub = await deriveCoreAccountXpub(wallet, password, 0, sdk) + const platformXpub = await derivePlatformAccountXpub(wallet, password, 0, sdk) + platformAddress = derivePlatformAddressesFromXpub(sdk, platformXpub, 'testnet', 0, 1)[0].address + await storage.set('network', 'testnet') + await storage.set('currentWalletId', scope.walletId) + await storage.set('wallet_testnet_wallet1', { ...wallet, coreXpubs: { 0: xpub }, platformXpubs: { 0: platformXpub }, platformAddressCounts: { 0: 1 } }) + walletRepository = new WalletRepository(storage, new IdentitiesRepository(storage, sdk)) + const receiving = deriveCoreAddressesFromXpub(sdk, xpub, 'testnet', 0, CoreAddressChain.receiving, 1)[0] + const change = deriveCoreAddressesFromXpub(sdk, xpub, 'testnet', 0, CoreAddressChain.change, 1)[0] + const parents = [receiving, change].map((entry, index) => new Transaction([], [Output.createP2PKH(2000000n + BigInt(index), entry.address)])) + core = { + network: 'testnet', + getTransaction: jest.fn(async (txid: string) => { + const tx = parents.find(tx => tx.hash() === txid) + if (tx == null) throw new Error('Not found') + return { transaction: tx.bytes(), confirmations: 6, isChainLocked: true, height: 100 } + }), + broadcastTransaction: jest.fn(async () => {}), + subscribeToTransactions: jest.fn(() => ({ async * [Symbol.asyncIterator] () {} })) + } + explorer = { + getXpubSummary: jest.fn(async () => ({ nextUnused: { receiving: 0, change: 0 }, balance: 4000001n })), + getXpubUtxos: jest.fn(async () => parents.map(tx => ({ address: tx.outputs[0].getAddress('testnet'), txid: tx.hash(), vout: 0, amount: tx.outputs[0].satoshis }))), + isAddressUsed: jest.fn(async () => false) + } + jest.spyOn(sdk.identities, 'getIdentityByPublicKeyHash').mockResolvedValue(null as any) + jest.spyOn(sdk.identities, 'getIdentityByNonUniquePublicKeyHash').mockResolvedValue(null as any) + jest.spyOn(sdk.platformAddresses, 'getAddressesInfos').mockResolvedValue([{ address: { toBech32m: () => platformAddress }, balance: 10000000000n, nonce: 7 }] as any) + jest.spyOn(sdk.stateTransitions, 'broadcast').mockResolvedValue(undefined) + jest.spyOn(sdk.stateTransitions, 'waitForStateTransitionResult').mockResolvedValue(undefined) + proofMock.mockImplementation(async (_core, _sdk, _tx, txid) => ({ type: 'chainLock', txid, outputIndex: 0, coreChainLockedHeight: 100 })) + service = new IdentityFundingService(walletRepository, sdk, core, explorer) + jest.spyOn(service, 'clientsFor').mockReturnValue({ sdk, core }) + request = { ...scope, operationId: 'operation-0000000001', source: 'core', kind: 'topUp', amountCredits: '3000000000', password, identityId } + }) + + const call = async (handler: { handle: (event: any) => Promise }, payload: any): Promise => + await handler.handle({ context: 'dash-platform-extension', id: 'id', method: 'IDENTITY_FUNDING', type: 'request', payload }) + const prepare = async (payload: PrepareIdentityFundingPayload): Promise => await call(new PrepareIdentityFundingHandler(walletRepository, service), payload) + const execute = async (): Promise => await call(new ExecuteIdentityFundingHandler(walletRepository, service, request.source, request.kind), { ...scope, operationId: request.operationId, password }) + const cancel = async (operationId: string): Promise => await call(new CancelIdentityFundingHandler(service), { ...scope, operationId }) + // Signed bytes stay in the journal: the handlers never return them. + const stored = async (operationId: string): Promise => await service.repository(scope).get(operationId) + + test('prepares a multi-address asset lock without network writes or stored secrets', async () => { + const operation = await prepare(request) + expect(operation.corePlan?.inputs).toHaveLength(2) + expect(new Set(operation.corePlan?.inputs.map(input => input.chain)).size).toBe(2) + expect(operation.corePlan?.creditOutputAddress).not.toBe(operation.corePlan?.inputs[0].address) + expect(core.broadcastTransaction).not.toHaveBeenCalled() + expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() + const journal = JSON.stringify(await service.repository(scope).getAll()) + expect(journal).not.toContain(mnemonic) + expect(journal).not.toContain(password) + expect(journal).not.toContain('privateKey') + expect((await prepare(request)).assetLockTxid).toBe(operation.assetLockTxid) + }) + + test('reserves inputs and indexes atomically for simultaneous prepares', async () => { + const results = await Promise.allSettled([prepare(request), prepare({ ...request, operationId: 'operation-0000000002' })]) + expect(results.map(result => result.status).sort()).toEqual(['fulfilled', 'rejected']) + expect(await service.repository(scope).getAll()).toHaveLength(1) + }) + + test('resumes the same raw tx after proof timeout, preserving wallet/network and all indexes', async () => { + const initial = await prepare(request) + proofMock.mockRejectedValueOnce(new Error('Proof timeout')) + await expect(execute()).rejects.toThrow('Proof timeout') + await storage.set('network', 'mainnet') + await storage.set('currentWalletId', 'other-wallet') + const retry = await execute() + expect(retry.status).toBe('completed') + expect(retry.assetLockTxid).toBe(initial.assetLockTxid) + expect(retry.topUpIndex).toBe(initial.topUpIndex) + expect(retry.changeIndex).toBe(initial.changeIndex) + expect(await storage.get('identityFunding_mainnet_other-wallet')).toBeNull() + expect(await execute()).toEqual(retry) + // The network took the asset lock the first time; a retry never re-sends it. + expect(core.broadcastTransaction).toHaveBeenCalledTimes(1) + expect(sdk.stateTransitions.broadcast).toHaveBeenCalledTimes(1) + }) + + test('stores Core and Platform bytes before each broadcast and recovers a lost Core response', async () => { + const operation = await prepare(request) + core.broadcastTransaction.mockImplementationOnce(async () => { + const saved = await service.repository(scope).get(operation.id) + expect(saved?.status).toBe('coreBroadcast') + expect(saved?.coreTransaction).toBe((await stored(operation.id)).coreTransaction) + throw new Error('Connection closed after broadcast') + }) + core.getTransaction.mockResolvedValue({ transaction: Transaction.fromHex((await stored(operation.id)).coreTransaction).bytes() }) + ;(sdk.stateTransitions.broadcast as jest.Mock).mockImplementation(async transition => { + const saved = await service.repository(scope).get(operation.id) + expect(saved?.status).toBe('platformBroadcast') + expect(saved?.stateTransitionHash).toBe(transition.hash(false)) + }) + expect((await execute()).status).toBe('completed') + }) + + test('does not treat a consumed-asset-lock error as success or spend another lock on retry', async () => { + const quote = await prepare(request) + ;(sdk.stateTransitions.broadcast as jest.Mock).mockRejectedValueOnce(new Error('Asset lock already consumed')) + + await expect(execute()).rejects.toThrow('Asset lock already consumed') + const pending = await stored(request.operationId) + // Not completed, not failed: the lock is on L1 and awaits a fresh transition. + expect(pending.status).toBe('proving') + expect(pending.error).toBe('Asset lock already consumed') + await expect(cancel(request.operationId)).rejects.toThrow('Only an unsubmitted') + + expect((await execute()).status).toBe('completed') + expect((await stored(request.operationId)).assetLockTxid).toBe(quote.assetLockTxid) + expect(core.broadcastTransaction).toHaveBeenCalledTimes(1) + expect(sdk.stateTransitions.broadcast).toHaveBeenCalledTimes(2) + }) + + test('cancels an unsubmitted quote, releases UTXO and keeps key/change indexes monotonic', async () => { + const first = await prepare(request) + await cancel(first.id) + const second = await prepare({ ...request, operationId: 'operation-0000000002' }) + expect(second.topUpIndex).toBe((first.topUpIndex as number) + 1) + expect(second.changeIndex).toBe((first.changeIndex as number) + 1) + expect(second.corePlan?.inputs).toEqual(first.corePlan?.inputs) + await expect(execute()).rejects.toThrow('cancelled') + }) + + test('rejects foreign or inconsistent outpoints before reserving or broadcasting', async () => { + const [owned] = await explorer.getXpubUtxos() + explorer.getXpubUtxos.mockResolvedValue([{ address: owned.address, txid: 'f'.repeat(64), vout: 0, amount: 9000000n }]) + await expect(prepare(request)).rejects.toThrow() + expect(await service.repository(scope).getAll()).toEqual([]) + expect(core.broadcastTransaction).not.toHaveBeenCalled() + }) + + test('Core registration saves a confirmed identity in its pinned wallet', async () => { + request = { ...request, kind: 'registration', identityId: undefined } + const quote = await prepare(request) + ;(sdk.identities.getIdentityByPublicKeyHash as jest.Mock).mockResolvedValue({ id: { base58: () => identityId } }) + const result = await execute() + expect(result.identityIndex).toBe(quote.identityIndex) + expect(result.identityId).toBe(identityId) + expect((await walletRepository.forScope(scope).getCurrent())?.currentIdentity).toBe(identityId) + expect(await new IdentitiesRepository(storage, sdk, scope).getAll()).toHaveLength(1) + }) + + test('a Core transaction the network refused fails the operation and frees its inputs', async () => { + const first = await prepare(request) + core.broadcastTransaction.mockRejectedValueOnce(new Error('bad-txns-inputs-missingorspent')) + core.getTransaction.mockImplementation(async (txid: string) => { + if (txid === first.assetLockTxid) { + throw new Error('Transaction not found') + } + throw new Error('Not found') + }) + + await expect(execute()).rejects.toThrow('bad-txns-inputs-missingorspent') + expect((await service.repository(scope).get(first.id))?.status).toBe('failed') + }) + + test('a transition Platform rejected on a Core asset lock is rebuilt on the same lock with a ChainLock proof', async () => { + const quote = await prepare(request) + ;(sdk.stateTransitions.broadcast as jest.Mock).mockRejectedValueOnce(new Error('Instant lock proof is too old')) + + await expect(execute()).rejects.toThrow('too old') + const recovering = await stored(quote.id) + expect(recovering.status).toBe('proving') + expect(recovering.chainLockProofOnly).toBe(true) + expect(recovering.assetLockProof).toBeUndefined() + expect(recovering.stateTransition).toBeUndefined() + expect(recovering.assetLockTxid).toBe(quote.assetLockTxid) + expect(recovering.topUpIndex).toBe(quote.topUpIndex) + + const done = await execute() + expect(done.status).toBe('completed') + // Same lock: the Core transaction went out once, the proof was awaited again + // without an InstantLock stream. + expect(core.broadcastTransaction).toHaveBeenCalledTimes(1) + expect(core.subscribeToTransactions).toHaveBeenCalledTimes(1) + expect(proofMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/content-script/fundingConflicts.spec.ts b/test/content-script/fundingConflicts.spec.ts new file mode 100644 index 00000000..83c21b9b --- /dev/null +++ b/test/content-script/fundingConflicts.spec.ts @@ -0,0 +1,28 @@ +import { canConflictWithFunding, findConflictingFunding } from '../../src/content-script/api/fundingConflicts' +import { MessagingMethods } from '../../src/types/enums/MessagingMethods' + +const operation = (kind: string, status: string = 'proving'): any => + ({ id: kind, walletId: 'w1', network: 'testnet', source: 'core', kind, status }) + +describe('fundingConflicts', () => { + it('blocks a legacy registration while a native registration is pending: they share the identity index', () => { + expect(findConflictingFunding(MessagingMethods.REGISTER_IDENTITY, [operation('registration')])?.kind).toBe('registration') + }) + + it('lets a legacy registration run alongside a pending top-up', () => { + expect(findConflictingFunding(MessagingMethods.REGISTER_IDENTITY, [operation('topUp')])).toBeUndefined() + }) + + it('ignores finished operations', () => { + for (const status of ['completed', 'cancelled', 'failed']) { + expect(findConflictingFunding(MessagingMethods.REGISTER_IDENTITY, [operation('registration', status)])).toBeUndefined() + } + }) + + it('does not read the journal for requests that cannot conflict', () => { + for (const method of [MessagingMethods.TOP_UP_IDENTITY, MessagingMethods.SEND_PLATFORM_TRANSFER, MessagingMethods.GET_CORE_BALANCE]) { + expect(canConflictWithFunding(method)).toBe(false) + expect(findConflictingFunding(method, [operation('registration')])).toBeUndefined() + } + }) +}) diff --git a/test/content-script/services/CoreExplorerService.spec.ts b/test/content-script/services/CoreExplorerService.spec.ts index ed7fa1d2..8bf317be 100644 --- a/test/content-script/services/CoreExplorerService.spec.ts +++ b/test/content-script/services/CoreExplorerService.spec.ts @@ -175,4 +175,35 @@ describe('CoreExplorerService', () => { await expect(service.getXpubSummary(XPUB, 'testnet')).rejects.toThrow('HTTP 500') }) }) + + describe('getXpubUtxos', () => { + const row = (i: number): unknown => ({ prevTxHash: String(i % 10).repeat(64), vOutIndex: i, address: `yAddr${i}`, amount: String(1000 + i) }) + const page = (rows: unknown[], total: number): unknown => ({ resultSet: rows, pagination: { page: 1, limit: 100, total } }) + + it('walks every page and posts the xpub in the body', async () => { + fetchMock + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => page(Array.from({ length: 100 }, (_, i) => row(i)), 120) }) + .mockResolvedValueOnce({ status: 200, ok: true, json: async () => page(Array.from({ length: 20 }, (_, i) => row(100 + i)), 120) }) + + const utxos = await service.getXpubUtxos('tpubXpub', 'testnet') + + expect(utxos).toHaveLength(120) + expect(utxos[0]).toEqual({ address: 'yAddr0', txid: '0'.repeat(64), vout: 0, amount: 1000n }) + expect(fetchMock.mock.calls[0][0]).toBe(`${testnetBase}/xpub/utxo`) + expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({ xpub: 'tpubXpub', page: 2, limit: 100 }) + }) + + it('stops on an empty page', async () => { + mockResponse({ json: page([], 5) }) + + expect(await service.getXpubUtxos('tpubXpub', 'testnet')).toEqual([]) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws on a non-OK response', async () => { + mockResponse({ status: 500, json: {} }) + + await expect(service.getXpubUtxos('tpubXpub', 'testnet')).rejects.toThrow('HTTP 500') + }) + }) }) diff --git a/test/helpers/isolatedStorage.ts b/test/helpers/isolatedStorage.ts new file mode 100644 index 00000000..bc42ad21 --- /dev/null +++ b/test/helpers/isolatedStorage.ts @@ -0,0 +1,32 @@ +import { StorageAdapter } from '../../src/content-script/storage/storageAdapter' + +// Round-trip JSON, like extension storage: a failed write must not mutate a +// previous record through a shared object reference. +export class IsolatedStorage implements StorageAdapter { + entries: Record = {} + async getAll (): Promise { return JSON.parse(JSON.stringify(this.entries)) } + async get (key: string): Promise { return JSON.parse(JSON.stringify(this.entries[key] ?? null)) } + async set (key: string, value: object | number | string | null): Promise { this.entries[key] = JSON.parse(JSON.stringify(value)) } + async remove (key: string): Promise { Reflect.deleteProperty(this.entries, key) } +} + +export const installWebLocks = (): (() => void) => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'navigator') + const queues = new Map>() + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: async (name: string, callback: () => Promise) => { + const result = (queues.get(name) ?? Promise.resolve()).catch(() => {}).then(callback) + queues.set(name, result) + return await result + } + } + } + }) + return () => { + if (descriptor != null) Object.defineProperty(globalThis, 'navigator', descriptor) + else Reflect.deleteProperty(globalThis, 'navigator') + } +} diff --git a/test/storage/identityFundingMigration.spec.ts b/test/storage/identityFundingMigration.spec.ts new file mode 100644 index 00000000..7ce2bec2 --- /dev/null +++ b/test/storage/identityFundingMigration.spec.ts @@ -0,0 +1,17 @@ +import runMigrations from '../../src/content-script/storage/runMigrations' +import { IsolatedStorage } from '../helpers/isolatedStorage' + +test('schema 10 preserves all wallet data and legacy deposit keys on repeat migration', async () => { + const storage = new IsolatedStorage() + const records = { + schema_version: 9, + wallets: ['wallet1'], + wallet_testnet_wallet1: { coreXpubs: { 0: 'core' }, platformXpubs: { 0: 'platform' }, shieldedAddressCounts: { 0: 25 } }, + assetLockFundingAddresses_testnet_wallet1: { legacy: { encryptedPrivateKey: 'encrypted', assetLockTxid: 'txid', registrationIdentityIndex: 3 } }, + identities_testnet_wallet1: { identity: { index: 2 } } + } + for (const [key, value] of Object.entries(records)) await storage.set(key, value) + await runMigrations(storage) + await runMigrations(storage) + expect(await storage.getAll()).toEqual({ ...records, schema_version: 10 }) +}) diff --git a/test/utils/buildAssetLockFromUtxos.spec.ts b/test/utils/buildAssetLockFromUtxos.spec.ts new file mode 100644 index 00000000..fe579737 --- /dev/null +++ b/test/utils/buildAssetLockFromUtxos.spec.ts @@ -0,0 +1,86 @@ +import { PrivateKey, Transaction, TransactionType, Output, Script, utils } from 'dash-core-sdk' +import { secp256k1 } from '@noble/curves/secp256k1.js' +import { buildAssetLockFromUtxos, selectAssetLockUtxos } from '../../src/utils/buildAssetLockFromUtxos' +import { CoreUtxo } from '../../src/types/CoreUtxo' +import { CoreAddressChain } from '../../src/types/enums/CoreAddressChain' + +describe.each(['mainnet', 'testnet'] as const)('native asset locks on %s', network => { + const keys = [1, 2, 3].map(value => PrivateKey.fromBytes(Uint8Array.from({ length: 32 }, (_, i) => i === 31 ? value : 0), network)) + const creditAddress = keys[2].getAddress() + const changeAddress = keys[1].getAddress() + const utxo = (key: number, amount: number, vout = 0): CoreUtxo => ({ + txid: (key + 1).toString(16).padStart(64, '0'), + vout, + amount: String(amount), + address: keys[key].getAddress(), + chain: key === 0 ? CoreAddressChain.receiving : CoreAddressChain.change, + index: 0, + derivationPath: `m/44'/${network === 'mainnet' ? 5 : 1}'/0'/${key}/0` + }) + + test('selects enough coins, returns native change, and preserves the credit owner', () => { + const plan = selectAssetLockUtxos([utxo(0, 200000), utxo(0, 100000, 1)], 50000n, creditAddress, changeAddress) + expect(plan.inputs).toHaveLength(1) + const tx = buildAssetLockFromUtxos(plan, [keys[0]]) + expect(tx.type).toBe(TransactionType.TRANSACTION_ASSET_LOCK) + expect(tx.outputs[0].satoshis).toBe(50000n) + expect(tx.outputs[1].getAddress(network)).toBe(changeAddress) + expect(BigInt(plan.feeDuffs)).toBeGreaterThanOrEqual(BigInt(tx.bytes().length)) + expect(BigInt(plan.feeDuffs) + tx.getOutputAmount()).toBe(200000n) + expect(Transaction.fromHex(tx.hex()).hex()).toBe(tx.hex()) + expect(tx.extraPayload?.bytes()).toEqual(new Uint8Array([1, 1, ...Output.createP2PKH(50000n, creditAddress).bytes()])) + }) + + test('independently verifies every signature when spending receiving and change addresses', () => { + const plan = selectAssetLockUtxos([utxo(0, 40000), utxo(1, 40000)], 70000n, creditAddress, changeAddress) + expect(plan.inputs).toHaveLength(2) + const signingKeys = plan.inputs.map(input => keys.find(key => key.getAddress() === input.address) as PrivateKey) + const tx = buildAssetLockFromUtxos(plan, signingKeys) + tx.inputs.forEach((input, index) => { + const chunks = input.scriptSig.parsedScriptChunks + const signature = new Uint8Array(chunks[0].data as ArrayBuffer) + const publicKey = new Uint8Array(chunks[1].data as ArrayBuffer) + expect(publicKey).toEqual(signingKeys[index].getPublicKey().bytes()) + const signable = Transaction.fromHex(tx.hex()) + signable.inputs.forEach((other, i) => { other.scriptSig = i === index ? Output.createP2PKH(0n, plan.inputs[i].address).script : new Script() }) + const bytes = new Uint8Array([...signable.bytes(), 1, 0, 0, 0]) + expect(secp256k1.verify(signature.slice(0, -1), utils.doubleSHA256(bytes), publicKey, { prehash: false, format: 'der', lowS: true })).toBe(true) + }) + expect(buildAssetLockFromUtxos(JSON.parse(JSON.stringify(plan)), signingKeys).hash()).toBe(tx.hash()) + }) + + test('selects several outputs of one address when necessary', () => { + const plan = selectAssetLockUtxos([utxo(0, 40000, 0), utxo(0, 40000, 1)], 70000n, creditAddress, changeAddress) + expect(buildAssetLockFromUtxos(plan, [keys[0], keys[0]]).inputs).toHaveLength(2) + }) + + test('handles exact spend and dust without a change output', () => { + const feeWithChange = BigInt(selectAssetLockUtxos([utxo(0, 100000)], 50000n, creditAddress, changeAddress).feeDuffs) + const feeWithoutChange = feeWithChange - BigInt(Output.createP2PKH(1n, changeAddress).bytes().length) + for (const dust of [0n, 100n, 545n]) { + const plan = selectAssetLockUtxos([utxo(0, Number(50000n + feeWithoutChange + dust))], 50000n, creditAddress, changeAddress) + expect(plan.changeDuffs).toBe('0') + expect(BigInt(plan.feeDuffs)).toBe(feeWithoutChange + dust) + expect(buildAssetLockFromUtxos(plan, [keys[0]]).outputs).toHaveLength(1) + } + }) + + test('excludes reserved outpoints and rejects insufficient, malformed or duplicate inputs', () => { + const input = utxo(0, 100000) + expect(() => selectAssetLockUtxos([input], 50000n, creditAddress, changeAddress, new Set([`${input.txid}:0`]))).toThrow('Insufficient') + expect(() => selectAssetLockUtxos([input], 100000n, creditAddress, changeAddress)).toThrow('Insufficient') + expect(() => selectAssetLockUtxos([input, input], 50000n, creditAddress, changeAddress)).toThrow('Duplicate') + expect(() => selectAssetLockUtxos([{ ...input, vout: -1 }], 50000n, creditAddress, changeAddress)).toThrow('Invalid') + expect(() => selectAssetLockUtxos([{ ...input, txid: 'bad' }], 50000n, creditAddress, changeAddress)).toThrow('Invalid') + const plan = selectAssetLockUtxos([input], 50000n, creditAddress, changeAddress) + expect(() => buildAssetLockFromUtxos(plan, [keys[1]])).toThrow('does not belong') + expect(() => buildAssetLockFromUtxos({ ...plan, changeDuffs: '1' }, [keys[0]])).toThrow('do not balance') + }) + + test('charges a larger fee for more inputs and scales the relay rate', () => { + const one = selectAssetLockUtxos([utxo(0, 100000)], 70000n, creditAddress, changeAddress) + const two = selectAssetLockUtxos([utxo(0, 40000), utxo(1, 40000)], 70000n, creditAddress, changeAddress) + expect(BigInt(two.feeDuffs)).toBe(BigInt(one.feeDuffs) + 149n) + expect(BigInt(selectAssetLockUtxos([utxo(0, 100000)], 70000n, creditAddress, changeAddress, new Set(), 2n).feeDuffs)).toBe(BigInt(one.feeDuffs) * 2n) + }) +}) diff --git a/test/utils/waitForAssetLockProof.spec.ts b/test/utils/waitForAssetLockProof.spec.ts new file mode 100644 index 00000000..a530552f --- /dev/null +++ b/test/utils/waitForAssetLockProof.spec.ts @@ -0,0 +1,33 @@ +import { DashCoreSDK, Transaction, TransactionType, Output, Script, ExtraPayload, PrivateKey } from 'dash-core-sdk' +import { DashPlatformSDK } from 'dash-platform-sdk' +import { waitForAssetLockProof } from '../../src/utils/waitForAssetLockProof' + +describe('asset lock proof fallback', () => { + const address = PrivateKey.fromBytes(new Uint8Array(32).fill(1), 'testnet').getAddress() + const tx = new Transaction([], [new Output(1000n, Script.fromASM('OP_RETURN OP_0'))], 0, 3, TransactionType.TRANSACTION_ASSET_LOCK, new ExtraPayload.AssetLockTx(1, 1, [Output.createP2PKH(1000n, address)])) + const subscription = { async * [Symbol.asyncIterator] () {} } + + test('continues chain-lock polling after the InstantLock stream closes', async () => { + const core = { getTransaction: jest.fn(async () => ({ isChainLocked: true, height: 100 })) } as unknown as DashCoreSDK + const sdk = { node: { status: jest.fn(async () => ({ chain: { coreChainLockedHeight: 100 } })) } } as unknown as DashPlatformSDK + await expect(waitForAssetLockProof(core, sdk, tx, tx.hash(), subscription, 1, 100)).resolves.toEqual({ type: 'chainLock', txid: tx.hash(), coreChainLockedHeight: 100, outputIndex: 0 }) + }) + + test('does not use a Core chain lock until Platform has reached the required height', async () => { + const core = { getTransaction: jest.fn(async () => ({ isChainLocked: true, height: 100 })) } as unknown as DashCoreSDK + const status = jest.fn().mockResolvedValueOnce({ chain: { coreChainLockedHeight: 99 } }).mockResolvedValue({ chain: { coreChainLockedHeight: 100 } }) + const sdk = { node: { status } } as unknown as DashPlatformSDK + await waitForAssetLockProof(core, sdk, tx, tx.hash(), subscription, 1, 100) + expect(status).toHaveBeenCalledTimes(2) + }) + + test('times out even if the InstantLock stream never emits or closes', async () => { + const core = { getTransaction: jest.fn(async () => ({ isChainLocked: false })) } as unknown as DashCoreSDK + const stream = { [Symbol.asyncIterator]: () => ({ next: async () => await new Promise>(() => {}) }) } + const sdk = {} as unknown as DashPlatformSDK + await expect(waitForAssetLockProof(core, sdk, tx, tx.hash(), stream, 1, 10)).rejects.toThrow('Timed out') + const calls = (core.getTransaction as jest.Mock).mock.calls.length + await new Promise(resolve => setTimeout(resolve, 15)) + expect(core.getTransaction).toHaveBeenCalledTimes(calls) + }) +}) From fcb165d67b458b253dc4fb74ec36a2d6c72f3b2f Mon Sep 17 00:00:00 2001 From: LexxXell Date: Sat, 26 Sep 2026 18:55:00 +0400 Subject: [PATCH 2/5] feat: register an identity from one of the wallet's own Platform addresses --- src/content-script/api/PrivateAPI.ts | 2 + src/content-script/api/fundingConflicts.ts | 11 ++-- .../identities/executeIdentityFunding.ts | 12 ++-- .../identities/getIdentityFundingSources.ts | 35 ++++++++-- .../identities/prepareIdentityFunding.ts | 23 ++++--- .../registerIdentityFromPlatformAddress.ts | 12 ++++ .../services/IdentityFundingService.ts | 64 +++++++++++++++++- src/types/IdentityFundingOperation.ts | 6 +- src/types/PrivateAPIClient.ts | 7 ++ src/types/enums/MessagingMethods.ts | 1 + .../payloads/PrepareIdentityFundingPayload.ts | 3 + .../GetIdentityFundingSourcesResponse.ts | 3 + .../identities/identityFunding.spec.ts | 66 +++++++++++++++++++ test/content-script/fundingConflicts.spec.ts | 23 ++++++- 14 files changed, 239 insertions(+), 29 deletions(-) create mode 100644 src/content-script/api/private/identities/registerIdentityFromPlatformAddress.ts diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index 56db38c7..d1bfdb99 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -47,6 +47,7 @@ import { GetIdentityFundingSourcesHandler } from './private/identities/getIdenti import { GetIdentityFundingOperationsHandler } from './private/identities/getIdentityFundingOperations' import { CancelIdentityFundingHandler } from './private/identities/cancelIdentityFunding' import { RegisterIdentityFromCoreHandler } from './private/identities/registerIdentityFromCore' +import { RegisterIdentityFromPlatformAddressHandler } from './private/identities/registerIdentityFromPlatformAddress' import { TopUpIdentityFromCoreHandler } from './private/identities/topUpIdentityFromCore' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' @@ -215,6 +216,7 @@ export class PrivateAPI { [MessagingMethods.GET_IDENTITY_FUNDING_OPERATIONS]: new GetIdentityFundingOperationsHandler(identityFunding), [MessagingMethods.CANCEL_IDENTITY_FUNDING]: new CancelIdentityFundingHandler(identityFunding), [MessagingMethods.REGISTER_IDENTITY_FROM_CORE]: new RegisterIdentityFromCoreHandler(walletRepository, identityFunding), + [MessagingMethods.REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS]: new RegisterIdentityFromPlatformAddressHandler(walletRepository, identityFunding), [MessagingMethods.TOP_UP_IDENTITY_FROM_CORE]: new TopUpIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.GENERATE_PLATFORM_ADDRESSES]: new GeneratePlatformAddressesHandler(walletRepository, this.sdk), [MessagingMethods.LIST_PLATFORM_ADDRESSES]: new ListPlatformAddressesHandler(walletRepository, this.sdk), diff --git a/src/content-script/api/fundingConflicts.ts b/src/content-script/api/fundingConflicts.ts index a2d6a5e4..d97c62bf 100644 --- a/src/content-script/api/fundingConflicts.ts +++ b/src/content-script/api/fundingConflicts.ts @@ -2,11 +2,14 @@ import { MessagingMethods } from '../../types/enums/MessagingMethods' import { IdentityFundingOperation } from '../../types/IdentityFundingOperation' import { isPendingFundingOperation } from '../repository/IdentityFundingRepository' -// Requests that draw on what a pending identity funding operation has reserved. -// A legacy registration picks its identity index the same way a native one does, -// so while a native registration is pending it would take the same index. +// Requests that draw on what a pending identity funding operation has reserved: +// its coins, its Platform address nonce, or — for a legacy registration — the +// identity index sequence. Everything else may run alongside. const CONFLICTS: Partial boolean>> = { - [MessagingMethods.REGISTER_IDENTITY]: operation => operation.kind === 'registration' + [MessagingMethods.REGISTER_IDENTITY]: operation => operation.kind === 'registration', + [MessagingMethods.SEND_PLATFORM_TRANSFER]: operation => operation.source === 'platform', + [MessagingMethods.WITHDRAW_PLATFORM_ADDRESS_TO_CORE]: operation => operation.source === 'platform', + [MessagingMethods.SHIELD_TO_POOL]: operation => operation.source === 'platform' } // Whether a method can conflict with any funding operation at all, so the diff --git a/src/content-script/api/private/identities/executeIdentityFunding.ts b/src/content-script/api/private/identities/executeIdentityFunding.ts index 1aa38794..a1f41d9a 100644 --- a/src/content-script/api/private/identities/executeIdentityFunding.ts +++ b/src/content-script/api/private/identities/executeIdentityFunding.ts @@ -15,10 +15,12 @@ import { isOutcomeUnknownError } from '../../../../utils/identityFundingErrors' import { fundingResponse, validateFundingScope } from './identityFundingPayload' // Confirms a prepared identity funding operation, or resumes one that was cut off. -// Every step sends bytes saved in the journal, so a retry never reselects coins. -// It broadcasts the asset lock, waits for its lock proof, builds the identity -// transition that spends it, then sends that and waits for Platform to confirm -// it. The subclasses fix which source and kind each API method drives. +// Every step sends bytes saved in the journal, so a retry never reselects coins +// or advances a Platform nonce. A Core operation broadcasts its asset lock, waits +// for a lock proof and builds the identity transition; a Platform operation has +// its transition signed already. Both then send that transition and wait for +// Platform to confirm it. The subclasses fix which source and kind each API +// method drives. export class ExecuteIdentityFundingHandler implements APIHandler { walletRepository: WalletRepository service: IdentityFundingService @@ -78,7 +80,7 @@ export class ExecuteIdentityFundingHandler implements APIHandler { private async execute (run: FundingRun, walletRepository: WalletRepository, wallet: Wallet, password: string, clients: IdentityFundingClients): Promise { const { sdk } = clients - if (run.operation.stateTransition == null) { + if (run.operation.source === 'core' && run.operation.stateTransition == null) { await this.fundAssetLock(run, wallet, password, clients) } diff --git a/src/content-script/api/private/identities/getIdentityFundingSources.ts b/src/content-script/api/private/identities/getIdentityFundingSources.ts index d127f732..30cd5061 100644 --- a/src/content-script/api/private/identities/getIdentityFundingSources.ts +++ b/src/content-script/api/private/identities/getIdentityFundingSources.ts @@ -8,8 +8,9 @@ import { validateFundingScope } from './identityFundingPayload' const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) -// What the wallet can put towards an identity: its Core balance, read by account -// xpub. A balance that cannot be read reports its error instead. +// What the wallet can put towards an identity: its Core balance and its Platform +// addresses, both read by account xpub — no password. Each source reports its own +// error, so one unreachable source does not hide the other. export class GetIdentityFundingSourcesHandler implements APIHandler { walletRepository: WalletRepository service: IdentityFundingService @@ -28,11 +29,33 @@ export class GetIdentityFundingSourcesHandler implements APIHandler { throw new Error('Native funding requires a seedphrase wallet') } - try { - return { core: { balanceCredits: await this.service.coreBalanceCredits(walletRepository, wallet.network) } } - } catch (error) { - return { core: { error: errorMessage(error) } } + const { sdk } = this.service.clientsFor(payload) + const result: GetIdentityFundingSourcesResponse = { core: {}, platform: { addresses: [] } } + + const readCore = async (): Promise => { + try { + result.core.balanceCredits = await this.service.coreBalanceCredits(walletRepository, wallet.network) + } catch (error) { + result.core.error = errorMessage(error) + } + } + + const readPlatform = async (): Promise => { + try { + const candidates = await this.service.platformCandidates(walletRepository, wallet.network, sdk) + + result.platform.addresses = candidates.map(candidate => ({ + address: candidate.platformAddress, + balanceCredits: candidate.balanceCredits.toString() + })) + } catch (error) { + result.platform.error = errorMessage(error) + } } + + await Promise.all([readCore(), readPlatform()]) + + return result } validatePayload (payload: RepositoryScope): string | null { diff --git a/src/content-script/api/private/identities/prepareIdentityFunding.ts b/src/content-script/api/private/identities/prepareIdentityFunding.ts index 109bc1a1..0782237f 100644 --- a/src/content-script/api/private/identities/prepareIdentityFunding.ts +++ b/src/content-script/api/private/identities/prepareIdentityFunding.ts @@ -15,10 +15,11 @@ import { fundingResponse, validateFundingScope } from './identityFundingPayload' const CREDITS_PER_DUFF = 1000n -// Quotes an identity registration or top-up paid from the wallet's own Core -// coins, and saves it to the journal with the asset lock signed, so confirming it -// never reselects coins. The same operation id returns the saved quote; a pending -// Core operation (or another registration) has to be resumed or cancelled first. +// Quotes an identity registration paid from the wallet's own funds — Core coins +// or one of its Platform addresses — and saves it to the journal with everything +// signed, so confirming it never reselects funds or advances a nonce twice. The +// same operation id returns the saved quote; a pending operation on the same +// source (or another registration) has to be resumed or cancelled first. export class PrepareIdentityFundingHandler implements APIHandler { walletRepository: WalletRepository service: IdentityFundingService @@ -55,8 +56,9 @@ export class PrepareIdentityFundingHandler implements APIHandler { } const operations = await repository.getAll() - // One pending operation per source: two would select the same coins. - // Registrations also share the identity index sequence. + // One pending operation per source: two would select the same coins or + // advance the same Platform nonce. Registrations also share the identity + // index sequence. const conflicting = operations.find(op => isPendingFundingOperation(op) && (op.source === payload.source || (op.kind === 'registration' && payload.kind === 'registration'))) @@ -82,6 +84,7 @@ export class PrepareIdentityFundingHandler implements APIHandler { source: payload.source, amountCredits: payload.amountCredits, identityId: payload.identityId, + requestedFromAddress: payload.fromAddress, status: 'prepared', createdAt: Date.now() } @@ -97,7 +100,11 @@ export class PrepareIdentityFundingHandler implements APIHandler { operation.identityIndex = await this.service.reserveIdentityIndex(wallet, payload.password, clients.sdk, taken) } - await this.quoteCore(operation, operations, legacy, walletRepository, wallet, payload.password, clients) + if (operation.source === 'core') { + await this.quoteCore(operation, operations, legacy, walletRepository, wallet, payload.password, clients) + } else { + Object.assign(operation, await this.service.quotePlatform(operation, walletRepository, wallet, payload.password, clients.sdk)) + } const feeCredits = BigInt(operation.feeCredits ?? '0') @@ -187,7 +194,7 @@ export class PrepareIdentityFundingHandler implements APIHandler { if (typeof payload.operationId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(payload.operationId)) { return 'Invalid operation id' } - if (payload.source !== 'core') { + if (!['core', 'platform'].includes(payload.source)) { return 'Invalid funding source' } if (payload.kind !== 'registration' && payload.kind !== 'topUp') { diff --git a/src/content-script/api/private/identities/registerIdentityFromPlatformAddress.ts b/src/content-script/api/private/identities/registerIdentityFromPlatformAddress.ts new file mode 100644 index 00000000..1b01045c --- /dev/null +++ b/src/content-script/api/private/identities/registerIdentityFromPlatformAddress.ts @@ -0,0 +1,12 @@ +import { ExecuteIdentityFundingHandler } from './executeIdentityFunding' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' + +// Confirms a registration quoted against one of the wallet's Platform addresses: +// the transition was signed at quote time, so this only broadcasts it and waits +// for Platform to confirm the identity. +export class RegisterIdentityFromPlatformAddressHandler extends ExecuteIdentityFundingHandler { + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + super(walletRepository, service, 'platform', 'registration') + } +} diff --git a/src/content-script/services/IdentityFundingService.ts b/src/content-script/services/IdentityFundingService.ts index 0c45dd66..02ca7ac1 100644 --- a/src/content-script/services/IdentityFundingService.ts +++ b/src/content-script/services/IdentityFundingService.ts @@ -15,11 +15,14 @@ import { deriveWalletHdKey, deriveIdentityPrivateKey, deriveIdentityRegistrationKey, - deriveIdentityTopUpKey + deriveIdentityTopUpKey, + derivePlatformAddressPrivateKey, + buildPlatformSourceCandidates } from '../../utils' import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' -import { buildIdentityCreateTransition, IDENTITY_KEY_DEFINITIONS } from '../../utils/identityRegistration' +import { buildIdentityCreateTransition, buildSignedIdentityCreateFromAddress, IDENTITY_KEY_DEFINITIONS } from '../../utils/identityRegistration' +import { PlatformSourceCandidate, selectPlatformSource } from '../../utils/platformTransfer' import { isTransitionAlreadyKnownError } from '../../utils/identityFundingErrors' import { isIdentityNotFoundError } from '../../utils/isIdentityNotFoundError' import { AssetLockProof } from '../../types/AssetLockProof' @@ -30,6 +33,16 @@ export interface IdentityFundingClients { core: DashCoreSDK } +// What a source hands back once its transition is signed: a Platform source pays +// straight from its balance, so nothing is left to build at execute time. +export interface IdentityFundingQuote { + stateTransition: string + stateTransitionHash: string + balanceCredits: string + feeCredits: string + fromAddress?: string +} + // Largest address window read from the explorer on one chain. const MAX_CORE_ADDRESS_WINDOW = 10000 @@ -334,4 +347,51 @@ export class IdentityFundingService { return false } + // ── Platform address source ────────────────────────────────────────────────── + + // The wallet's Platform addresses with what each one holds, read from its + // cached account xpub — no password needed to look, only to spend. + async platformCandidates (walletRepository: WalletRepository, network: Wallet['network'], sdk: DashPlatformSDK): Promise { + const xpub = await walletRepository.getPlatformAccountXpub(0) + + if (xpub == null) { + throw new Error('Platform xpub is not initialized; unlock this wallet first') + } + + const count = await walletRepository.getPlatformAddressCount(0) + + if (count === 0) { + throw new Error('No Platform addresses have been created yet') + } + + return await buildPlatformSourceCandidates(sdk, xpub, network, 0, count) + } + + // Signs the identity create transition funded from one Platform address: the one + // asked for, or the largest covering the amount. The nonce is baked into the + // signature here, which is why the journal stores the bytes and a retry sends + // exactly these — a re-signed transition with a stale nonce would be rejected, + // and one with a fresh nonce could pay twice. + async quotePlatform (operation: IdentityFundingOperation, walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const candidates = await this.platformCandidates(walletRepository, wallet.network, sdk) + const source = selectPlatformSource(candidates, BigInt(operation.amountCredits), operation.requestedFromAddress) + const key = await derivePlatformAddressPrivateKey(wallet, password, 0, source.index, sdk) + + const transition = buildSignedIdentityCreateFromAddress( + sdk, + await this.identityKeys(operation, wallet, password, sdk), + source.platformAddress, + source.nonce, + BigInt(operation.amountCredits), + key + ) + + return { + stateTransition: transition.hex(), + stateTransitionHash: transition.hash(false), + balanceCredits: source.balanceCredits.toString(), + feeCredits: transition.calculateMinRequiredFee().toString(), + fromAddress: source.platformAddress + } + } } diff --git a/src/types/IdentityFundingOperation.ts b/src/types/IdentityFundingOperation.ts index 4aa46e7d..d02f7e4f 100644 --- a/src/types/IdentityFundingOperation.ts +++ b/src/types/IdentityFundingOperation.ts @@ -3,7 +3,7 @@ import { CoreAssetLockPlan } from '../utils/buildAssetLockFromUtxos' import { AssetLockProof } from './AssetLockProof' // The wallet funds an identity is paid from. -export type IdentityFundingSource = 'core' +export type IdentityFundingSource = 'core' | 'platform' export interface IdentityFundingOperation extends RepositoryScope { id: string @@ -12,6 +12,10 @@ export interface IdentityFundingOperation extends RepositoryScope { account: number amountCredits: string identityId?: string + // Platform source: the address the funds come from, and the one the caller + // asked for (kept so a repeated prepare can tell the same request apart). + fromAddress?: string + requestedFromAddress?: string identityIndex?: number topUpIndex?: number changeIndex?: number diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index 3e8bc9aa..d0740c8e 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -488,6 +488,13 @@ export class PrivateAPIClient { return await this._rpcCall(MessagingMethods.REGISTER_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } + // Confirms a registration quoted against a Platform address. The transition was + // signed when the quote was prepared, so this broadcasts it and waits for the + // identity; a retry sends the same bytes. + async registerIdentityFromPlatformAddress (payload: ExecuteIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) + } + async topUpIdentityFromCore (payload: ExecuteIdentityFundingPayload): Promise { return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } diff --git a/src/types/enums/MessagingMethods.ts b/src/types/enums/MessagingMethods.ts index ca104330..96c7f2e0 100644 --- a/src/types/enums/MessagingMethods.ts +++ b/src/types/enums/MessagingMethods.ts @@ -45,6 +45,7 @@ export enum MessagingMethods { GET_IDENTITY_FUNDING_OPERATIONS = 'GET_IDENTITY_FUNDING_OPERATIONS', CANCEL_IDENTITY_FUNDING = 'CANCEL_IDENTITY_FUNDING', REGISTER_IDENTITY_FROM_CORE = 'REGISTER_IDENTITY_FROM_CORE', + REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS = 'REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS', TOP_UP_IDENTITY_FROM_CORE = 'TOP_UP_IDENTITY_FROM_CORE', GENERATE_PLATFORM_ADDRESSES = 'GENERATE_PLATFORM_ADDRESSES', LIST_PLATFORM_ADDRESSES = 'LIST_PLATFORM_ADDRESSES', diff --git a/src/types/messages/payloads/PrepareIdentityFundingPayload.ts b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts index 02bdef63..556e61c2 100644 --- a/src/types/messages/payloads/PrepareIdentityFundingPayload.ts +++ b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts @@ -11,4 +11,7 @@ export interface PrepareIdentityFundingPayload extends RepositoryScope { password: string // the identity to top up; not accepted for a registration identityId?: string + // Platform source only: one address to pay from; omitted, the largest covering + // address is used. + fromAddress?: string } diff --git a/src/types/messages/response/GetIdentityFundingSourcesResponse.ts b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts index 186a8c2b..da73b9c2 100644 --- a/src/types/messages/response/GetIdentityFundingSourcesResponse.ts +++ b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts @@ -1,4 +1,7 @@ export interface GetIdentityFundingSourcesResponse { // The Core balance in credits (as a string), or why it could not be read. core: { balanceCredits?: string, error?: string } + // Every Platform address of the wallet with what it holds, so a caller can show + // which ones can fund an identity, or why the list could not be read. + platform: { addresses: Array<{ address: string, balanceCredits: string }>, error?: string } } diff --git a/test/api/private/identities/identityFunding.spec.ts b/test/api/private/identities/identityFunding.spec.ts index 3df1f788..000ecc11 100644 --- a/test/api/private/identities/identityFunding.spec.ts +++ b/test/api/private/identities/identityFunding.spec.ts @@ -6,6 +6,9 @@ import { IdentityFundingService } from '../../../../src/content-script/services/ import { PrepareIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/prepareIdentityFunding' import { ExecuteIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/executeIdentityFunding' import { CancelIdentityFundingHandler } from '../../../../src/content-script/api/private/identities/cancelIdentityFunding' +import { GetIdentityFundingSourcesHandler } from '../../../../src/content-script/api/private/identities/getIdentityFundingSources' +import { findConflictingFunding } from '../../../../src/content-script/api/fundingConflicts' +import { MessagingMethods } from '../../../../src/types/enums/MessagingMethods' import { IdentitiesRepository } from '../../../../src/content-script/repository/IdentitiesRepository' import { WalletRepository } from '../../../../src/content-script/repository/WalletRepository' import { bytesToHex, utf8ToBytes, derivePlatformAccountXpub, derivePlatformAddressesFromXpub } from '../../../../src/utils' @@ -83,6 +86,10 @@ describe('identity funding handlers', () => { const prepare = async (payload: PrepareIdentityFundingPayload): Promise => await call(new PrepareIdentityFundingHandler(walletRepository, service), payload) const execute = async (): Promise => await call(new ExecuteIdentityFundingHandler(walletRepository, service, request.source, request.kind), { ...scope, operationId: request.operationId, password }) const cancel = async (operationId: string): Promise => await call(new CancelIdentityFundingHandler(service), { ...scope, operationId }) + const sources = async (): Promise => await call(new GetIdentityFundingSourcesHandler(walletRepository, service), scope) + // A registration paid from a Platform address, the request shape PR A adds. + const platformRequest = (overrides: Partial = {}): PrepareIdentityFundingPayload => + ({ ...request, source: 'platform', kind: 'registration', identityId: undefined, amountCredits: '3000000000', ...overrides }) // Signed bytes stay in the journal: the handlers never return them. const stored = async (operationId: string): Promise => await service.repository(scope).get(operationId) @@ -222,4 +229,63 @@ describe('identity funding handlers', () => { expect(core.subscribeToTransactions).toHaveBeenCalledTimes(1) expect(proofMock).toHaveBeenCalledTimes(2) }) + test('quotes a Platform registration against the funding address without writing anywhere', async () => { + const operation = await prepare(platformRequest()) + + expect(operation.fromAddress).toBe(platformAddress) + expect(operation.balanceCredits).toBe('10000000000') + expect(BigInt(operation.feeCredits as string)).toBeGreaterThan(0n) + // A Platform source signs its transition now, so nothing is left to build. + expect((await stored(operation.id)).stateTransition).toBeDefined() + expect(operation.assetLockTxid).toBeUndefined() + expect(core.broadcastTransaction).not.toHaveBeenCalled() + expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() + // The nonce is signed into those bytes, so a repeat prepare must not re-sign. + expect((await prepare(platformRequest())).stateTransitionHash).toBe(operation.stateTransitionHash) + }) + + test('confirms a Platform registration by sending exactly the signed bytes', async () => { + const quote = await prepare(platformRequest()) + const saved = (await stored(quote.id)).stateTransition + ;(sdk.identities.getIdentityByPublicKeyHash as jest.Mock).mockResolvedValue({ id: { base58: () => identityId } }) + + request = { ...platformRequest() } + const result = await execute() + + expect(result.identityId).toBe(identityId) + expect(result.status).toBe('completed') + expect((sdk.stateTransitions.broadcast as jest.Mock).mock.calls[0][0].hex()).toBe(saved) + // The Core side is untouched: this source never builds an asset lock. + expect(core.broadcastTransaction).not.toHaveBeenCalled() + expect(proofMock).not.toHaveBeenCalled() + }) + + test('refuses a source address the wallet does not own', async () => { + await expect(prepare(platformRequest({ fromAddress: 'tdash1notours' }))) + .rejects.toThrow(/not/) + expect(await service.repository(scope).getAll()).toHaveLength(0) + }) + + test('holds the address nonce against other spends while the operation is pending', async () => { + await prepare(platformRequest()) + const operations = await service.repository(scope).getAll() + + expect(findConflictingFunding(MessagingMethods.SEND_PLATFORM_TRANSFER, operations)?.source).toBe('platform') + expect(findConflictingFunding(MessagingMethods.SHIELD_TO_POOL, operations)?.source).toBe('platform') + // Reads and other funds are untouched by the reservation. + expect(findConflictingFunding(MessagingMethods.GET_CORE_BALANCE, operations)).toBeUndefined() + }) + + test('lists both sources with their balances, each reporting its own failure', async () => { + const result = await sources() + + expect(result.platform.addresses).toEqual([{ address: platformAddress, balanceCredits: '10000000000' }]) + expect(result.platform.error).toBeUndefined() + + explorer.getXpubSummary.mockRejectedValueOnce(new Error('explorer down')) + const degraded = await sources() + + expect(degraded.core.error).toContain('explorer down') + expect(degraded.platform.addresses).toHaveLength(1) + }) }) diff --git a/test/content-script/fundingConflicts.spec.ts b/test/content-script/fundingConflicts.spec.ts index 83c21b9b..b6d476d8 100644 --- a/test/content-script/fundingConflicts.spec.ts +++ b/test/content-script/fundingConflicts.spec.ts @@ -1,8 +1,8 @@ import { canConflictWithFunding, findConflictingFunding } from '../../src/content-script/api/fundingConflicts' import { MessagingMethods } from '../../src/types/enums/MessagingMethods' -const operation = (kind: string, status: string = 'proving'): any => - ({ id: kind, walletId: 'w1', network: 'testnet', source: 'core', kind, status }) +const operation = (kind: string, status: string = 'proving', source: string = 'core'): any => + ({ id: kind, walletId: 'w1', network: 'testnet', source, kind, status }) describe('fundingConflicts', () => { it('blocks a legacy registration while a native registration is pending: they share the identity index', () => { @@ -19,8 +19,25 @@ describe('fundingConflicts', () => { } }) + // A pending Platform operation has the address nonce signed into its + // transition, so anything else spending that address would invalidate it. + it('blocks spends of the Platform address while an operation on it is pending', () => { + const pending = [operation('registration', 'proving', 'platform')] + + for (const method of [MessagingMethods.SEND_PLATFORM_TRANSFER, MessagingMethods.WITHDRAW_PLATFORM_ADDRESS_TO_CORE, MessagingMethods.SHIELD_TO_POOL]) { + expect(canConflictWithFunding(method)).toBe(true) + expect(findConflictingFunding(method, pending)?.source).toBe('platform') + } + }) + + it('lets Platform spends run while only a Core operation is pending', () => { + for (const method of [MessagingMethods.SEND_PLATFORM_TRANSFER, MessagingMethods.SHIELD_TO_POOL]) { + expect(findConflictingFunding(method, [operation('registration')])).toBeUndefined() + } + }) + it('does not read the journal for requests that cannot conflict', () => { - for (const method of [MessagingMethods.TOP_UP_IDENTITY, MessagingMethods.SEND_PLATFORM_TRANSFER, MessagingMethods.GET_CORE_BALANCE]) { + for (const method of [MessagingMethods.TOP_UP_IDENTITY, MessagingMethods.GET_CORE_BALANCE]) { expect(canConflictWithFunding(method)).toBe(false) expect(findConflictingFunding(method, [operation('registration')])).toBeUndefined() } From e8736d03dfed891cf8604e16707656f73512131a Mon Sep 17 00:00:00 2001 From: LexxXell Date: Sun, 27 Sep 2026 18:06:13 +0400 Subject: [PATCH 3/5] fix: validate the Platform source address before selecting funds --- .../api/private/identities/prepareIdentityFunding.ts | 5 +++++ test/api/private/identities/identityFunding.spec.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/content-script/api/private/identities/prepareIdentityFunding.ts b/src/content-script/api/private/identities/prepareIdentityFunding.ts index 0782237f..5196980c 100644 --- a/src/content-script/api/private/identities/prepareIdentityFunding.ts +++ b/src/content-script/api/private/identities/prepareIdentityFunding.ts @@ -206,6 +206,11 @@ export class PrepareIdentityFundingHandler implements APIHandler { if (typeof payload.amountCredits !== 'string' || !/^[1-9]\d{0,18}$/.test(payload.amountCredits)) { return 'Amount must be a positive integer string of credits' } + // Only a Platform source picks an address; an empty or non-string one would + // otherwise reach the selection and fail there with a vaguer message. + if (payload.fromAddress != null && (payload.source !== 'platform' || typeof payload.fromAddress !== 'string' || payload.fromAddress.length === 0)) { + return 'Select one Platform source address or automatic selection' + } if (payload.kind === 'topUp' && !validateIdentifier(payload.identityId ?? '')) { return 'Invalid target identity' } diff --git a/test/api/private/identities/identityFunding.spec.ts b/test/api/private/identities/identityFunding.spec.ts index 000ecc11..de91413e 100644 --- a/test/api/private/identities/identityFunding.spec.ts +++ b/test/api/private/identities/identityFunding.spec.ts @@ -260,6 +260,16 @@ describe('identity funding handlers', () => { expect(proofMock).not.toHaveBeenCalled() }) + test('validates the source address before anything is selected', async () => { + const handler = new PrepareIdentityFundingHandler(walletRepository, service) + + expect(handler.validatePayload(platformRequest({ fromAddress: '' }))).toMatch(/Platform source address/) + expect(handler.validatePayload(platformRequest({ fromAddress: 42 as any }))).toMatch(/Platform source address/) + // A Core operation has no address to pick. + expect(handler.validatePayload({ ...request, fromAddress: platformAddress })).toMatch(/Platform source address/) + expect(handler.validatePayload(platformRequest({ fromAddress: platformAddress }))).toBeNull() + }) + test('refuses a source address the wallet does not own', async () => { await expect(prepare(platformRequest({ fromAddress: 'tdash1notours' }))) .rejects.toThrow(/not/) From a5c74296e6fa5caed1094d604f3cf09914b558b0 Mon Sep 17 00:00:00 2001 From: LexxXell Date: Sun, 27 Sep 2026 17:40:41 +0400 Subject: [PATCH 4/5] feat: top up an identity from one of the wallet's own Platform addresses --- src/content-script/api/PrivateAPI.ts | 2 + .../topUpIdentityFromPlatformAddress.ts | 12 +++++ .../services/IdentityFundingService.ts | 34 +++++++++----- src/types/PrivateAPIClient.ts | 6 +++ src/types/enums/MessagingMethods.ts | 1 + .../identities/identityFunding.spec.ts | 44 +++++++++++++++++++ 6 files changed, 87 insertions(+), 12 deletions(-) create mode 100644 src/content-script/api/private/identities/topUpIdentityFromPlatformAddress.ts diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index d1bfdb99..fdc271c5 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -48,6 +48,7 @@ import { GetIdentityFundingOperationsHandler } from './private/identities/getIde import { CancelIdentityFundingHandler } from './private/identities/cancelIdentityFunding' import { RegisterIdentityFromCoreHandler } from './private/identities/registerIdentityFromCore' import { RegisterIdentityFromPlatformAddressHandler } from './private/identities/registerIdentityFromPlatformAddress' +import { TopUpIdentityFromPlatformAddressHandler } from './private/identities/topUpIdentityFromPlatformAddress' import { TopUpIdentityFromCoreHandler } from './private/identities/topUpIdentityFromCore' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' @@ -217,6 +218,7 @@ export class PrivateAPI { [MessagingMethods.CANCEL_IDENTITY_FUNDING]: new CancelIdentityFundingHandler(identityFunding), [MessagingMethods.REGISTER_IDENTITY_FROM_CORE]: new RegisterIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS]: new RegisterIdentityFromPlatformAddressHandler(walletRepository, identityFunding), + [MessagingMethods.TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS]: new TopUpIdentityFromPlatformAddressHandler(walletRepository, identityFunding), [MessagingMethods.TOP_UP_IDENTITY_FROM_CORE]: new TopUpIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.GENERATE_PLATFORM_ADDRESSES]: new GeneratePlatformAddressesHandler(walletRepository, this.sdk), [MessagingMethods.LIST_PLATFORM_ADDRESSES]: new ListPlatformAddressesHandler(walletRepository, this.sdk), diff --git a/src/content-script/api/private/identities/topUpIdentityFromPlatformAddress.ts b/src/content-script/api/private/identities/topUpIdentityFromPlatformAddress.ts new file mode 100644 index 00000000..b58bcf2f --- /dev/null +++ b/src/content-script/api/private/identities/topUpIdentityFromPlatformAddress.ts @@ -0,0 +1,12 @@ +import { ExecuteIdentityFundingHandler } from './executeIdentityFunding' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' + +// Confirms a top-up quoted against one of the wallet's Platform addresses: the +// transition was signed at quote time, so this only broadcasts it and waits for +// Platform to credit the identity. +export class TopUpIdentityFromPlatformAddressHandler extends ExecuteIdentityFundingHandler { + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + super(walletRepository, service, 'platform', 'topUp') + } +} diff --git a/src/content-script/services/IdentityFundingService.ts b/src/content-script/services/IdentityFundingService.ts index 02ca7ac1..753208a7 100644 --- a/src/content-script/services/IdentityFundingService.ts +++ b/src/content-script/services/IdentityFundingService.ts @@ -22,7 +22,7 @@ import { import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' import { buildIdentityCreateTransition, buildSignedIdentityCreateFromAddress, IDENTITY_KEY_DEFINITIONS } from '../../utils/identityRegistration' -import { PlatformSourceCandidate, selectPlatformSource } from '../../utils/platformTransfer' +import { buildSignedIdentityTopUpFromAddress, PlatformSourceCandidate, selectPlatformSource } from '../../utils/platformTransfer' import { isTransitionAlreadyKnownError } from '../../utils/identityFundingErrors' import { isIdentityNotFoundError } from '../../utils/isIdentityNotFoundError' import { AssetLockProof } from '../../types/AssetLockProof' @@ -367,24 +367,34 @@ export class IdentityFundingService { return await buildPlatformSourceCandidates(sdk, xpub, network, 0, count) } - // Signs the identity create transition funded from one Platform address: the one + // Signs the transition funded from one Platform address — create for a + // registration, top-up for an existing identity — against the address the caller // asked for, or the largest covering the amount. The nonce is baked into the // signature here, which is why the journal stores the bytes and a retry sends - // exactly these — a re-signed transition with a stale nonce would be rejected, - // and one with a fresh nonce could pay twice. + // exactly these: a re-signed transition with a stale nonce would be rejected, and + // one with a fresh nonce could pay twice. async quotePlatform (operation: IdentityFundingOperation, walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { const candidates = await this.platformCandidates(walletRepository, wallet.network, sdk) const source = selectPlatformSource(candidates, BigInt(operation.amountCredits), operation.requestedFromAddress) const key = await derivePlatformAddressPrivateKey(wallet, password, 0, source.index, sdk) - const transition = buildSignedIdentityCreateFromAddress( - sdk, - await this.identityKeys(operation, wallet, password, sdk), - source.platformAddress, - source.nonce, - BigInt(operation.amountCredits), - key - ) + const transition = operation.kind === 'registration' + ? buildSignedIdentityCreateFromAddress( + sdk, + await this.identityKeys(operation, wallet, password, sdk), + source.platformAddress, + source.nonce, + BigInt(operation.amountCredits), + key + ) + : buildSignedIdentityTopUpFromAddress( + sdk, + operation.identityId as string, + source.platformAddress, + source.nonce, + BigInt(operation.amountCredits), + key + ) return { stateTransition: transition.hex(), diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index d0740c8e..cf43c25e 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -495,6 +495,12 @@ export class PrivateAPIClient { return await this._rpcCall(MessagingMethods.REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } + // Confirms a top-up quoted against a Platform address. Same contract as the + // registration above: the signed bytes are sent as they are. + async topUpIdentityFromPlatformAddress (payload: ExecuteIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) + } + async topUpIdentityFromCore (payload: ExecuteIdentityFundingPayload): Promise { return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } diff --git a/src/types/enums/MessagingMethods.ts b/src/types/enums/MessagingMethods.ts index 96c7f2e0..87f2c636 100644 --- a/src/types/enums/MessagingMethods.ts +++ b/src/types/enums/MessagingMethods.ts @@ -46,6 +46,7 @@ export enum MessagingMethods { CANCEL_IDENTITY_FUNDING = 'CANCEL_IDENTITY_FUNDING', REGISTER_IDENTITY_FROM_CORE = 'REGISTER_IDENTITY_FROM_CORE', REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS = 'REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS', + TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS = 'TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS', TOP_UP_IDENTITY_FROM_CORE = 'TOP_UP_IDENTITY_FROM_CORE', GENERATE_PLATFORM_ADDRESSES = 'GENERATE_PLATFORM_ADDRESSES', LIST_PLATFORM_ADDRESSES = 'LIST_PLATFORM_ADDRESSES', diff --git a/test/api/private/identities/identityFunding.spec.ts b/test/api/private/identities/identityFunding.spec.ts index de91413e..4381ec83 100644 --- a/test/api/private/identities/identityFunding.spec.ts +++ b/test/api/private/identities/identityFunding.spec.ts @@ -298,4 +298,48 @@ describe('identity funding handlers', () => { expect(degraded.core.error).toContain('explorer down') expect(degraded.platform.addresses).toHaveLength(1) }) + test('quotes a Platform top-up of an existing identity against the same address', async () => { + const operation = await prepare(platformRequest({ kind: 'topUp', identityId, operationId: 'operation-0000000003' })) + + expect(operation.kind).toBe('topUp') + expect(operation.identityId).toBe(identityId) + expect(operation.fromAddress).toBe(platformAddress) + expect((await stored(operation.id)).stateTransition).toBeDefined() + // A top-up needs no identity key and no index: the identity already exists. + expect(operation.identityIndex).toBeUndefined() + expect(sdk.stateTransitions.broadcast).not.toHaveBeenCalled() + }) + + test('confirms a Platform top-up by sending the signed bytes and keeps the identity', async () => { + request = platformRequest({ kind: 'topUp', identityId, operationId: 'operation-0000000003' }) + const quote = await prepare(request) + const saved = (await stored(quote.id)).stateTransition + + const result = await execute() + + expect(result.status).toBe('completed') + expect(result.identityId).toBe(identityId) + expect((sdk.stateTransitions.broadcast as jest.Mock).mock.calls[0][0].hex()).toBe(saved) + // Crediting an identity creates nothing, so no identity lookup is made. + expect(sdk.identities.getIdentityByPublicKeyHash).not.toHaveBeenCalled() + expect(core.broadcastTransaction).not.toHaveBeenCalled() + }) + + test('refuses a Platform top-up without a valid identity', async () => { + const handler = new PrepareIdentityFundingHandler(walletRepository, service) + + expect(handler.validatePayload(platformRequest({ kind: 'topUp', identityId: undefined }))).toMatch(/identity/i) + expect(handler.validatePayload(platformRequest({ kind: 'topUp', identityId: 'not-an-identifier' }))).toMatch(/identity/i) + expect(handler.validatePayload(platformRequest({ kind: 'topUp', identityId }))).toBeNull() + }) + + test('keeps a pending top-up from being confirmed as a registration', async () => { + request = platformRequest({ kind: 'topUp', identityId, operationId: 'operation-0000000003' }) + await prepare(request) + + const asRegistration = new ExecuteIdentityFundingHandler(walletRepository, service, 'platform', 'registration') + + await expect(call(asRegistration, { ...scope, operationId: request.operationId, password })) + .rejects.toThrow('Funding operation does not match this request') + }) }) From e2da6ed6599b51fbb4a5d18962eea30937931a2e Mon Sep 17 00:00:00 2001 From: LexxXell Date: Sun, 27 Sep 2026 17:59:27 +0400 Subject: [PATCH 5/5] feat: register an identity from the wallet's shielded pool --- src/content-script/api/PrivateAPI.ts | 2 + src/content-script/api/fundingConflicts.ts | 9 +- .../identities/executeIdentityFunding.ts | 20 ++- .../identities/getIdentityFundingSources.ts | 55 +++++-- .../identities/identityFundingPayload.ts | 5 + .../identities/prepareIdentityFunding.ts | 42 ++++-- .../registerIdentityFromShieldedPool.ts | 12 ++ .../services/IdentityFundingService.ts | 139 +++++++++++++++++- src/types/IdentityFundingOperation.ts | 8 +- src/types/PrivateAPIClient.ts | 6 + src/types/enums/MessagingMethods.ts | 1 + .../GetIdentityFundingSourcesPayload.ts | 7 + .../payloads/PrepareIdentityFundingPayload.ts | 2 + .../GetIdentityFundingSourcesResponse.ts | 4 + .../identities/identityFunding.spec.ts | 98 +++++++++++- 15 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 src/content-script/api/private/identities/registerIdentityFromShieldedPool.ts create mode 100644 src/types/messages/payloads/GetIdentityFundingSourcesPayload.ts diff --git a/src/content-script/api/PrivateAPI.ts b/src/content-script/api/PrivateAPI.ts index fdc271c5..da841840 100644 --- a/src/content-script/api/PrivateAPI.ts +++ b/src/content-script/api/PrivateAPI.ts @@ -49,6 +49,7 @@ import { CancelIdentityFundingHandler } from './private/identities/cancelIdentit import { RegisterIdentityFromCoreHandler } from './private/identities/registerIdentityFromCore' import { RegisterIdentityFromPlatformAddressHandler } from './private/identities/registerIdentityFromPlatformAddress' import { TopUpIdentityFromPlatformAddressHandler } from './private/identities/topUpIdentityFromPlatformAddress' +import { RegisterIdentityFromShieldedPoolHandler } from './private/identities/registerIdentityFromShieldedPool' import { TopUpIdentityFromCoreHandler } from './private/identities/topUpIdentityFromCore' import { RequestAssetLockFundingAddressHandler } from './private/assetLocks/requestAssetLockFundingAddress' import { RequestTopUpFundingAddressHandler } from './private/assetLocks/requestTopUpFundingAddress' @@ -219,6 +220,7 @@ export class PrivateAPI { [MessagingMethods.REGISTER_IDENTITY_FROM_CORE]: new RegisterIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS]: new RegisterIdentityFromPlatformAddressHandler(walletRepository, identityFunding), [MessagingMethods.TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS]: new TopUpIdentityFromPlatformAddressHandler(walletRepository, identityFunding), + [MessagingMethods.REGISTER_IDENTITY_FROM_SHIELDED_POOL]: new RegisterIdentityFromShieldedPoolHandler(walletRepository, identityFunding), [MessagingMethods.TOP_UP_IDENTITY_FROM_CORE]: new TopUpIdentityFromCoreHandler(walletRepository, identityFunding), [MessagingMethods.GENERATE_PLATFORM_ADDRESSES]: new GeneratePlatformAddressesHandler(walletRepository, this.sdk), [MessagingMethods.LIST_PLATFORM_ADDRESSES]: new ListPlatformAddressesHandler(walletRepository, this.sdk), diff --git a/src/content-script/api/fundingConflicts.ts b/src/content-script/api/fundingConflicts.ts index d97c62bf..e5fecaaa 100644 --- a/src/content-script/api/fundingConflicts.ts +++ b/src/content-script/api/fundingConflicts.ts @@ -3,13 +3,16 @@ import { IdentityFundingOperation } from '../../types/IdentityFundingOperation' import { isPendingFundingOperation } from '../repository/IdentityFundingRepository' // Requests that draw on what a pending identity funding operation has reserved: -// its coins, its Platform address nonce, or — for a legacy registration — the -// identity index sequence. Everything else may run alongside. +// its coins, its Platform address nonce, its shielded notes, or — for a legacy +// registration — the identity index sequence. Everything else may run alongside. const CONFLICTS: Partial boolean>> = { [MessagingMethods.REGISTER_IDENTITY]: operation => operation.kind === 'registration', [MessagingMethods.SEND_PLATFORM_TRANSFER]: operation => operation.source === 'platform', [MessagingMethods.WITHDRAW_PLATFORM_ADDRESS_TO_CORE]: operation => operation.source === 'platform', - [MessagingMethods.SHIELD_TO_POOL]: operation => operation.source === 'platform' + [MessagingMethods.SHIELD_TO_POOL]: operation => operation.source === 'platform', + [MessagingMethods.SEND_SHIELDED_TRANSFER]: operation => operation.source === 'shielded', + [MessagingMethods.UNSHIELD_TO_ADDRESS]: operation => operation.source === 'shielded', + [MessagingMethods.WITHDRAW_SHIELDED_TO_CORE]: operation => operation.source === 'shielded' } // Whether a method can conflict with any funding operation at all, so the diff --git a/src/content-script/api/private/identities/executeIdentityFunding.ts b/src/content-script/api/private/identities/executeIdentityFunding.ts index a1f41d9a..048c0e4f 100644 --- a/src/content-script/api/private/identities/executeIdentityFunding.ts +++ b/src/content-script/api/private/identities/executeIdentityFunding.ts @@ -109,7 +109,8 @@ export class ExecuteIdentityFundingHandler implements APIHandler { const identityId = await this.service.saveRegisteredIdentity(run.operation, walletRepository, wallet, password, sdk) if (identityId == null) { - throw new Error('Confirmed registration has no identity yet; retry to resolve it') + await this.reportMissingIdentity(run) + return } await run.update({ identityId }) @@ -118,6 +119,23 @@ export class ExecuteIdentityFundingHandler implements APIHandler { await run.update({ status: 'completed', error: undefined }) } + // The transition executed but created no identity. Out of the shielded pool that + // is a real outcome: when creation fails the protocol sends the denomination to + // the fallback Platform address instead, and there is nothing left to retry. + // Everywhere else the identity must exist, and a retry looks it up again. + private async reportMissingIdentity (run: FundingRun): Promise { + const { source, fallbackAddress } = run.operation + + if (source !== 'shielded' || fallbackAddress == null) { + throw new Error('Confirmed registration has no identity yet; retry to resolve it') + } + + await run.update({ + status: 'failed', + error: `Platform executed the transition but created no identity. The protocol returns the funds of a failed creation to your Platform address ${fallbackAddress}; check its balance` + }) + } + // Broadcasts the saved asset lock, waits for its InstantLock or ChainLock proof // and saves the identity transition that spends it. private async fundAssetLock (run: FundingRun, wallet: Wallet, password: string, clients: IdentityFundingClients): Promise { diff --git a/src/content-script/api/private/identities/getIdentityFundingSources.ts b/src/content-script/api/private/identities/getIdentityFundingSources.ts index 30cd5061..4ac8f62d 100644 --- a/src/content-script/api/private/identities/getIdentityFundingSources.ts +++ b/src/content-script/api/private/identities/getIdentityFundingSources.ts @@ -1,16 +1,18 @@ import { APIHandler } from '../../APIHandler' import { EventData } from '../../../../types/EventData' -import { RepositoryScope } from '../../../../types/RepositoryScope' +import { GetIdentityFundingSourcesPayload } from '../../../../types/messages/payloads/GetIdentityFundingSourcesPayload' import { GetIdentityFundingSourcesResponse } from '../../../../types/messages/response/GetIdentityFundingSourcesResponse' import { WalletRepository } from '../../../repository/WalletRepository' import { IdentityFundingService } from '../../../services/IdentityFundingService' -import { validateFundingScope } from './identityFundingPayload' +import { validateFundingScope, SHIELDED_TOP_UP_UNAVAILABLE } from './identityFundingPayload' const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error) -// What the wallet can put towards an identity: its Core balance and its Platform -// addresses, both read by account xpub — no password. Each source reports its own -// error, so one unreachable source does not hide the other. +// What the wallet can put towards an identity: its Core balance, its Platform +// addresses and what the shielded pool allows. Core and Platform are read by +// account xpub, so they need no password; the shielded balance is included only +// when one is given, because the notes are recovered with the viewing key. Each +// source reports its own error, so one unreachable source does not hide the rest. export class GetIdentityFundingSourcesHandler implements APIHandler { walletRepository: WalletRepository service: IdentityFundingService @@ -21,7 +23,7 @@ export class GetIdentityFundingSourcesHandler implements APIHandler { } async handle (event: EventData): Promise { - const payload: RepositoryScope = event.payload + const payload: GetIdentityFundingSourcesPayload = event.payload const walletRepository = this.walletRepository.forScope(payload) const wallet = await walletRepository.getCurrent() @@ -30,7 +32,11 @@ export class GetIdentityFundingSourcesHandler implements APIHandler { } const { sdk } = this.service.clientsFor(payload) - const result: GetIdentityFundingSourcesResponse = { core: {}, platform: { addresses: [] } } + const result: GetIdentityFundingSourcesResponse = { + core: {}, + platform: { addresses: [] }, + shielded: { denominations: [], topUpError: SHIELDED_TOP_UP_UNAVAILABLE } + } const readCore = async (): Promise => { try { @@ -53,12 +59,41 @@ export class GetIdentityFundingSourcesHandler implements APIHandler { } } - await Promise.all([readCore(), readPlatform()]) + const readShielded = async (): Promise => { + try { + const protocolVersion = await this.service.shieldedProtocolVersion(sdk) + + result.shielded.protocolVersion = protocolVersion + result.shielded.denominations = this.service.shieldedDenominations(protocolVersion) + + if (result.shielded.denominations.length === 0) { + result.shielded.error = `Shielded registration is unsupported on protocol ${protocolVersion ?? 'unknown'} by this SDK` + } + + if (payload.password != null && payload.password.length > 0) { + result.shielded.balanceCredits = await this.service.shieldedBalanceCredits(wallet, payload.password, sdk) + } + } catch (error) { + result.shielded.error = errorMessage(error) + } + } + + await Promise.all([readCore(), readPlatform(), readShielded()]) return result } - validatePayload (payload: RepositoryScope): string | null { - return validateFundingScope(payload) + validatePayload (payload: GetIdentityFundingSourcesPayload): string | null { + const scopeError = validateFundingScope(payload) + + if (scopeError != null) { + return scopeError + } + + if (payload.password != null && typeof payload.password !== 'string') { + return 'Invalid password' + } + + return null } } diff --git a/src/content-script/api/private/identities/identityFundingPayload.ts b/src/content-script/api/private/identities/identityFundingPayload.ts index a5d79c0f..22e20151 100644 --- a/src/content-script/api/private/identities/identityFundingPayload.ts +++ b/src/content-script/api/private/identities/identityFundingPayload.ts @@ -1,6 +1,11 @@ import { RepositoryScope } from '../../../../types/RepositoryScope' import { IdentityFundingOperation } from '../../../../types/IdentityFundingOperation' +// The pool can create an identity but cannot credit an existing one: that +// transition needs drive protocol v14, which is not released. Callers get this +// message instead of a failure after a proof. +export const SHIELDED_TOP_UP_UNAVAILABLE = 'Direct shielded identity top-up requires protocol v14 support that is not published yet' + // Funding requests always name the wallet and network they run against, so a // switch of the selected wallet can never retarget an operation. export const validateFundingScope = (payload: RepositoryScope): string | null => { diff --git a/src/content-script/api/private/identities/prepareIdentityFunding.ts b/src/content-script/api/private/identities/prepareIdentityFunding.ts index 5196980c..d4c8f1f9 100644 --- a/src/content-script/api/private/identities/prepareIdentityFunding.ts +++ b/src/content-script/api/private/identities/prepareIdentityFunding.ts @@ -11,15 +11,16 @@ import { IdentityFundingClients, IdentityFundingService } from '../../../service import { AssetLockFundingAddressSchema } from '../../../storage/storageSchema' import { decryptMnemonic, validateIdentifier } from '../../../../utils' import { selectAssetLockUtxos } from '../../../../utils/buildAssetLockFromUtxos' -import { fundingResponse, validateFundingScope } from './identityFundingPayload' +import { fundingResponse, validateFundingScope, SHIELDED_TOP_UP_UNAVAILABLE } from './identityFundingPayload' const CREDITS_PER_DUFF = 1000n -// Quotes an identity registration paid from the wallet's own funds — Core coins -// or one of its Platform addresses — and saves it to the journal with everything -// signed, so confirming it never reselects funds or advances a nonce twice. The -// same operation id returns the saved quote; a pending operation on the same -// source (or another registration) has to be resumed or cancelled first. +// Quotes an identity registration or top-up paid from the wallet's own funds — +// Core coins, one of its Platform addresses or the shielded pool — and saves it to +// the journal with everything signed, so confirming it never reselects funds, +// advances a nonce twice or proves again. The same operation id returns the saved +// quote; a pending operation on the same source (or another registration) has to +// be resumed or cancelled first. export class PrepareIdentityFundingHandler implements APIHandler { walletRepository: WalletRepository service: IdentityFundingService @@ -56,9 +57,9 @@ export class PrepareIdentityFundingHandler implements APIHandler { } const operations = await repository.getAll() - // One pending operation per source: two would select the same coins or - // advance the same Platform nonce. Registrations also share the identity - // index sequence. + // One pending operation per source: two would select the same coins, advance + // the same Platform nonce or spend the same notes. Registrations also share + // the identity index sequence. const conflicting = operations.find(op => isPendingFundingOperation(op) && (op.source === payload.source || (op.kind === 'registration' && payload.kind === 'registration'))) @@ -66,6 +67,12 @@ export class PrepareIdentityFundingHandler implements APIHandler { throw new Error(`Resume or cancel the pending ${conflicting.source} funding operation first`) } + // The pool can create an identity but cannot credit one yet, so a shielded + // top-up is refused here rather than after a proof was built. + if (payload.source === 'shielded' && payload.kind === 'topUp') { + throw new Error(SHIELDED_TOP_UP_UNAVAILABLE) + } + const clients = this.service.clientsFor(payload) const legacy = await new AssetLockFundingAddressesRepository(repository.storageAdapter, payload).getAll() @@ -85,6 +92,7 @@ export class PrepareIdentityFundingHandler implements APIHandler { amountCredits: payload.amountCredits, identityId: payload.identityId, requestedFromAddress: payload.fromAddress, + fromAddresses: payload.fromAddresses == null ? undefined : [...payload.fromAddresses].sort(), status: 'prepared', createdAt: Date.now() } @@ -103,7 +111,11 @@ export class PrepareIdentityFundingHandler implements APIHandler { if (operation.source === 'core') { await this.quoteCore(operation, operations, legacy, walletRepository, wallet, payload.password, clients) } else { - Object.assign(operation, await this.service.quotePlatform(operation, walletRepository, wallet, payload.password, clients.sdk)) + const quote = operation.source === 'platform' + ? await this.service.quotePlatform(operation, walletRepository, wallet, payload.password, clients.sdk) + : await this.service.quoteShielded(operation, walletRepository, wallet, payload.password, clients.sdk) + + Object.assign(operation, quote) } const feeCredits = BigInt(operation.feeCredits ?? '0') @@ -194,7 +206,7 @@ export class PrepareIdentityFundingHandler implements APIHandler { if (typeof payload.operationId !== 'string' || !/^[a-zA-Z0-9-]{16,80}$/.test(payload.operationId)) { return 'Invalid operation id' } - if (!['core', 'platform'].includes(payload.source)) { + if (!['core', 'platform', 'shielded'].includes(payload.source)) { return 'Invalid funding source' } if (payload.kind !== 'registration' && payload.kind !== 'topUp') { @@ -211,6 +223,14 @@ export class PrepareIdentityFundingHandler implements APIHandler { if (payload.fromAddress != null && (payload.source !== 'platform' || typeof payload.fromAddress !== 'string' || payload.fromAddress.length === 0)) { return 'Select one Platform source address or automatic selection' } + if (payload.fromAddresses != null) { + const valid = payload.source === 'shielded' && Array.isArray(payload.fromAddresses) && payload.fromAddresses.length > 0 && + payload.fromAddresses.every(address => typeof address === 'string' && address.length > 0) + + if (!valid) { + return 'Invalid shielded receiving-address filter' + } + } if (payload.kind === 'topUp' && !validateIdentifier(payload.identityId ?? '')) { return 'Invalid target identity' } diff --git a/src/content-script/api/private/identities/registerIdentityFromShieldedPool.ts b/src/content-script/api/private/identities/registerIdentityFromShieldedPool.ts new file mode 100644 index 00000000..a011072d --- /dev/null +++ b/src/content-script/api/private/identities/registerIdentityFromShieldedPool.ts @@ -0,0 +1,12 @@ +import { ExecuteIdentityFundingHandler } from './executeIdentityFunding' +import { WalletRepository } from '../../../repository/WalletRepository' +import { IdentityFundingService } from '../../../services/IdentityFundingService' + +// Confirms a registration quoted against the shielded pool. The proof was built +// when the quote was prepared, so this only broadcasts the transition and waits +// for Platform to create the identity. +export class RegisterIdentityFromShieldedPoolHandler extends ExecuteIdentityFundingHandler { + constructor (walletRepository: WalletRepository, service: IdentityFundingService) { + super(walletRepository, service, 'shielded', 'registration') + } +} diff --git a/src/content-script/services/IdentityFundingService.ts b/src/content-script/services/IdentityFundingService.ts index 753208a7..0eec12d8 100644 --- a/src/content-script/services/IdentityFundingService.ts +++ b/src/content-script/services/IdentityFundingService.ts @@ -1,4 +1,5 @@ import { DashPlatformSDK } from 'dash-platform-sdk' +import { PlatformVersionWASM, RecoveredNoteWASM } from 'pshenmic-dpp' import { DashCoreSDK, Output, PrivateKey, Transaction } from 'dash-core-sdk' import { KeyType, Network, PrivateKeyWASM, StateTransitionWASM } from 'dash-platform-sdk/types' import { WalletRepository } from '../repository/WalletRepository' @@ -17,7 +18,12 @@ import { deriveIdentityRegistrationKey, deriveIdentityTopUpKey, derivePlatformAddressPrivateKey, - buildPlatformSourceCandidates + buildPlatformSourceCandidates, + derivePlatformAccountXpub, + derivePlatformAddressesFromXpub, + loadUnspentShieldedNotes, + decryptMnemonic, + UnspentShieldedNotes } from '../../utils' import { CoreAddressEntry, deriveCoreAccountXpub, deriveCoreAddressesFromXpub } from '../../utils/coreAddresses' import { CoreAssetLockPlan, buildAssetLockFromUtxos } from '../../utils/buildAssetLockFromUtxos' @@ -26,7 +32,7 @@ import { buildSignedIdentityTopUpFromAddress, PlatformSourceCandidate, selectPla import { isTransitionAlreadyKnownError } from '../../utils/identityFundingErrors' import { isIdentityNotFoundError } from '../../utils/isIdentityNotFoundError' import { AssetLockProof } from '../../types/AssetLockProof' -import { IDENTITY_INDEX_SCAN_LIMIT } from '../../constants' +import { IDENTITY_INDEX_SCAN_LIMIT, PLATFORM_ADDRESS_COIN_TYPE, SHIELDED_MAX_SPEND_NOTES } from '../../constants' export interface IdentityFundingClients { sdk: DashPlatformSDK @@ -41,6 +47,8 @@ export interface IdentityFundingQuote { balanceCredits: string feeCredits: string fromAddress?: string + fallbackAddress?: string + protocolVersion?: number } // Largest address window read from the explorer on one chain. @@ -404,4 +412,131 @@ export class IdentityFundingService { fromAddress: source.platformAddress } } + // ── Shielded pool source ───────────────────────────────────────────────────── + + // The drive protocol the network runs, which decides both the denominations an + // identity may be created with and the platform version the proof is built for. + async shieldedProtocolVersion (sdk: DashPlatformSDK): Promise { + return (await sdk.node.status()).version?.protocol?.drive?.current + } + + // Creating an identity out of the pool is only allowed at fixed denominations, + // so the amount is not free-form. An unknown protocol yields an empty list, + // which is how the caller learns the source is unavailable. + shieldedDenominations (protocolVersion: number | undefined): string[] { + if (protocolVersion === 12) { + return ['10000000000', '30000000000', '50000000000', '100000000000'] + } + if (protocolVersion === 13) { + return ['3000000000', '10000000000', '25000000000', '50000000000', '100000000000'] + } + + return [] + } + + // The wallet's unspent notes, plus the whole note set the spend has to be + // witnessed against. `fromAddresses` narrows the notes to those receiving + // addresses. (Reads the pool through the shared helper for now; it moves into + // ShieldedService with #170.) + async loadShieldedNotes (seed: Uint8Array, network: Wallet['network'], fromAddresses?: string[]): Promise { + return await loadUnspentShieldedNotes(this.sdk, seed, network, 0, fromAddresses) + } + + // What the pool holds for this wallet, for showing the source alongside the + // others. Needs the password: the notes are recovered with the viewing key. + async shieldedBalanceCredits (wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const seed = sdk.keyPair.mnemonicToSeed(decryptMnemonic(wallet, password)) + const { unspent } = await this.loadShieldedNotes(seed, wallet.network) + + return unspent.reduce((total, note) => total + note.note.value, 0n).toString() + } + + // The notes that cover a denomination, largest first. One transition may spend + // at most SHIELDED_MAX_SPEND_NOTES notes, so a balance spread over more notes + // than that cannot fund the identity even when the total looks sufficient. + selectShieldedIdentityNotes (notes: RecoveredNoteWASM[], denomination: bigint): RecoveredNoteWASM[] { + const sorted = [...notes].sort((left, right) => left.note.value > right.note.value ? -1 : 1) + let total = 0n + + for (let count = 0; count < Math.min(sorted.length, SHIELDED_MAX_SPEND_NOTES); count++) { + // The denomination is the gross pool exit; the fee comes out of it. + total += sorted[count].note.value + + if (total >= denomination) { + return sorted.slice(0, count + 1) + } + } + + throw new Error(`Insufficient shielded funds within the ${SHIELDED_MAX_SPEND_NOTES}-note action limit`) + } + + // The Platform address the protocol returns the funds to when creation fails. + // It is the wallet's first Platform address, cached like any other so it can be + // shown later without the password; an xpub that does not match this seed means + // the record belongs to another wallet and is never overwritten silently. + async shieldedFallbackAddress (walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const stored = await walletRepository.getPlatformAccountXpub(0) + const derived = await derivePlatformAccountXpub(wallet, password, 0, sdk) + + if (stored != null && stored !== derived) { + throw new Error('Platform fallback xpub does not belong to this seed') + } + + if (stored == null) { + await walletRepository.setPlatformAccountXpub(0, derived) + } + + if (await walletRepository.getPlatformAddressCount(0) < 1) { + await walletRepository.setPlatformAddressCount(0, 1) + } + + return derivePlatformAddressesFromXpub(sdk, derived, wallet.network, 0, 1, 0)[0].address + } + + // Builds and proves the identity create transition that spends shielded notes. + // Proving is the slow part (Halo 2), and it happens here, at quote time: the + // journal then holds a transition that a retry can send unchanged. + async quoteShielded (operation: IdentityFundingOperation, walletRepository: WalletRepository, wallet: Wallet, password: string, sdk: DashPlatformSDK): Promise { + const protocolVersion = await this.shieldedProtocolVersion(sdk) + + if (!this.shieldedDenominations(protocolVersion).includes(operation.amountCredits)) { + throw new Error(`Shielded identity registration is unavailable for this denomination or protocol (${protocolVersion ?? 'unknown'}); this SDK supports v12 and v13`) + } + + const seed = sdk.keyPair.mnemonicToSeed(decryptMnemonic(wallet, password)) + const { allNotes, unspent } = await this.loadShieldedNotes(seed, wallet.network, operation.fromAddresses) + const notes = this.selectShieldedIdentityNotes(unspent, BigInt(operation.amountCredits)) + const { spends, anchor } = sdk.shielded.buildSpendableNotes(allNotes, notes) + + const fallbackAddress = await this.shieldedFallbackAddress(walletRepository, wallet, password, sdk) + const privateKeys = await this.identityKeys(operation, wallet, password, sdk) + + const transition = await sdk.shielded.createStateTransition('identityCreateFromShieldedPool', { + publicKeys: IDENTITY_KEY_DEFINITIONS.map((definition, index) => ({ + ...definition, + readOnly: false, + data: Uint8Array.from(privateKeys[index].getPublicKey().bytes()) + })), + privateKeys, + denomination: BigInt(operation.amountCredits), + sendToAddressOnCreationFailure: fallbackAddress, + spends, + anchor, + seed, + account: 0, + coinType: PLATFORM_ADDRESS_COIN_TYPE[wallet.network], + changeAddress: sdk.keyPair.deriveShieldedAddress(seed, wallet.network, 0), + platformVersion: protocolVersion === 12 ? PlatformVersionWASM.PLATFORM_V12 : PlatformVersionWASM.PLATFORM_V13 + }) + + return { + stateTransition: transition.hex(), + stateTransitionHash: transition.hash(false), + balanceCredits: unspent.reduce((total, note) => total + note.note.value, 0n).toString(), + // The SDK's minimum estimate; the protocol settles the final net balance. + feeCredits: transition.calculateMinRequiredFee().toString(), + fallbackAddress, + protocolVersion + } + } } diff --git a/src/types/IdentityFundingOperation.ts b/src/types/IdentityFundingOperation.ts index d02f7e4f..5dbc51ee 100644 --- a/src/types/IdentityFundingOperation.ts +++ b/src/types/IdentityFundingOperation.ts @@ -3,7 +3,7 @@ import { CoreAssetLockPlan } from '../utils/buildAssetLockFromUtxos' import { AssetLockProof } from './AssetLockProof' // The wallet funds an identity is paid from. -export type IdentityFundingSource = 'core' | 'platform' +export type IdentityFundingSource = 'core' | 'platform' | 'shielded' export interface IdentityFundingOperation extends RepositoryScope { id: string @@ -16,6 +16,12 @@ export interface IdentityFundingOperation extends RepositoryScope { // asked for (kept so a repeated prepare can tell the same request apart). fromAddress?: string requestedFromAddress?: string + // Shielded source: the receiving addresses the notes may be drawn from, the + // Platform address the protocol pays back to if creation fails, and the drive + // protocol the denomination and proof were built for. + fromAddresses?: string[] + fallbackAddress?: string + protocolVersion?: number identityIndex?: number topUpIndex?: number changeIndex?: number diff --git a/src/types/PrivateAPIClient.ts b/src/types/PrivateAPIClient.ts index cf43c25e..36511860 100644 --- a/src/types/PrivateAPIClient.ts +++ b/src/types/PrivateAPIClient.ts @@ -501,6 +501,12 @@ export class PrivateAPIClient { return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } + // Confirms a registration quoted against the shielded pool. The proof is already + // in the journal, so this sends it; a retry sends the same bytes. + async registerIdentityFromShieldedPool (payload: ExecuteIdentityFundingPayload): Promise { + return await this._rpcCall(MessagingMethods.REGISTER_IDENTITY_FROM_SHIELDED_POOL, payload, SHIELDED_PROVE_TIMEOUT) + } + async topUpIdentityFromCore (payload: ExecuteIdentityFundingPayload): Promise { return await this._rpcCall(MessagingMethods.TOP_UP_IDENTITY_FROM_CORE, payload, BLOCKCHAIN_MESSAGING_TIMEOUT) } diff --git a/src/types/enums/MessagingMethods.ts b/src/types/enums/MessagingMethods.ts index 87f2c636..d89f0e74 100644 --- a/src/types/enums/MessagingMethods.ts +++ b/src/types/enums/MessagingMethods.ts @@ -47,6 +47,7 @@ export enum MessagingMethods { REGISTER_IDENTITY_FROM_CORE = 'REGISTER_IDENTITY_FROM_CORE', REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS = 'REGISTER_IDENTITY_FROM_PLATFORM_ADDRESS', TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS = 'TOP_UP_IDENTITY_FROM_PLATFORM_ADDRESS', + REGISTER_IDENTITY_FROM_SHIELDED_POOL = 'REGISTER_IDENTITY_FROM_SHIELDED_POOL', TOP_UP_IDENTITY_FROM_CORE = 'TOP_UP_IDENTITY_FROM_CORE', GENERATE_PLATFORM_ADDRESSES = 'GENERATE_PLATFORM_ADDRESSES', LIST_PLATFORM_ADDRESSES = 'LIST_PLATFORM_ADDRESSES', diff --git a/src/types/messages/payloads/GetIdentityFundingSourcesPayload.ts b/src/types/messages/payloads/GetIdentityFundingSourcesPayload.ts new file mode 100644 index 00000000..1a81f206 --- /dev/null +++ b/src/types/messages/payloads/GetIdentityFundingSourcesPayload.ts @@ -0,0 +1,7 @@ +import { RepositoryScope } from '../../RepositoryScope' + +export interface GetIdentityFundingSourcesPayload extends RepositoryScope { + // Optional: with it the shielded balance is read too, since recovering the + // wallet's notes needs the viewing key. + password?: string +} diff --git a/src/types/messages/payloads/PrepareIdentityFundingPayload.ts b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts index 556e61c2..93b5c91b 100644 --- a/src/types/messages/payloads/PrepareIdentityFundingPayload.ts +++ b/src/types/messages/payloads/PrepareIdentityFundingPayload.ts @@ -14,4 +14,6 @@ export interface PrepareIdentityFundingPayload extends RepositoryScope { // Platform source only: one address to pay from; omitted, the largest covering // address is used. fromAddress?: string + // Shielded source only: restrict the notes to these receiving addresses. + fromAddresses?: string[] } diff --git a/src/types/messages/response/GetIdentityFundingSourcesResponse.ts b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts index da73b9c2..29edd122 100644 --- a/src/types/messages/response/GetIdentityFundingSourcesResponse.ts +++ b/src/types/messages/response/GetIdentityFundingSourcesResponse.ts @@ -4,4 +4,8 @@ export interface GetIdentityFundingSourcesResponse { // Every Platform address of the wallet with what it holds, so a caller can show // which ones can fund an identity, or why the list could not be read. platform: { addresses: Array<{ address: string, balanceCredits: string }>, error?: string } + // The pool creates an identity only at fixed denominations, which depend on the + // drive protocol; an empty list means this SDK cannot do it on that protocol. + // The balance is included only when the request carried a password. + shielded: { denominations: string[], protocolVersion?: number, balanceCredits?: string, error?: string, topUpError: string } } diff --git a/test/api/private/identities/identityFunding.spec.ts b/test/api/private/identities/identityFunding.spec.ts index 4381ec83..33e9e9b6 100644 --- a/test/api/private/identities/identityFunding.spec.ts +++ b/test/api/private/identities/identityFunding.spec.ts @@ -1,4 +1,5 @@ import { DashPlatformSDK } from 'dash-platform-sdk' +import { StateTransitionWASM } from 'dash-platform-sdk/types' import { Transaction, Output } from 'dash-core-sdk' import { PrivateKey, encrypt } from 'eciesjs' import hash from 'hash.js' @@ -76,6 +77,13 @@ describe('identity funding handlers', () => { jest.spyOn(sdk.stateTransitions, 'broadcast').mockResolvedValue(undefined) jest.spyOn(sdk.stateTransitions, 'waitForStateTransitionResult').mockResolvedValue(undefined) proofMock.mockImplementation(async (_core, _sdk, _tx, txid) => ({ type: 'chainLock', txid, outputIndex: 0, coreChainLockedHeight: 100 })) + jest.spyOn(sdk.node, 'status').mockResolvedValue({ version: { protocol: { drive: { current: 13 } } } } as any) + jest.spyOn(sdk.shielded, 'buildSpendableNotes').mockReturnValue({ spends: ['spend'], anchor: Uint8Array.from([1]) } as any) + jest.spyOn(sdk.shielded, 'createStateTransition').mockResolvedValue({ + hex: () => 'deadbeef', + hash: () => 'shielded-transition-hash', + calculateMinRequiredFee: () => 111n + } as any) service = new IdentityFundingService(walletRepository, sdk, core, explorer) jest.spyOn(service, 'clientsFor').mockReturnValue({ sdk, core }) request = { ...scope, operationId: 'operation-0000000001', source: 'core', kind: 'topUp', amountCredits: '3000000000', password, identityId } @@ -86,7 +94,11 @@ describe('identity funding handlers', () => { const prepare = async (payload: PrepareIdentityFundingPayload): Promise => await call(new PrepareIdentityFundingHandler(walletRepository, service), payload) const execute = async (): Promise => await call(new ExecuteIdentityFundingHandler(walletRepository, service, request.source, request.kind), { ...scope, operationId: request.operationId, password }) const cancel = async (operationId: string): Promise => await call(new CancelIdentityFundingHandler(service), { ...scope, operationId }) - const sources = async (): Promise => await call(new GetIdentityFundingSourcesHandler(walletRepository, service), scope) + const sources = async (payload: any = scope): Promise => await call(new GetIdentityFundingSourcesHandler(walletRepository, service), payload) + // A registration paid out of the shielded pool, at a protocol v13 denomination. + const shieldedRequest = (overrides: Partial = {}): PrepareIdentityFundingPayload => + ({ ...request, source: 'shielded', kind: 'registration', identityId: undefined, amountCredits: '3000000000', operationId: 'operation-0000000004', ...overrides }) + const shieldedNote = (value: bigint): any => ({ note: { value, address: { toBech32m: () => 'orchard1' } }, _rawRecoveredNote: { nullifier: Uint8Array.from([1]) } }) // A registration paid from a Platform address, the request shape PR A adds. const platformRequest = (overrides: Partial = {}): PrepareIdentityFundingPayload => ({ ...request, source: 'platform', kind: 'registration', identityId: undefined, amountCredits: '3000000000', ...overrides }) @@ -342,4 +354,88 @@ describe('identity funding handlers', () => { await expect(call(asRegistration, { ...scope, operationId: request.operationId, password })) .rejects.toThrow('Funding operation does not match this request') }) + test('quotes a shielded registration at an allowed denomination, proving once', async () => { + jest.spyOn(service, 'loadShieldedNotes' as any).mockResolvedValue({ allNotes: ['note'], unspent: [shieldedNote(4000000000n)] }) + + const operation = await prepare(shieldedRequest()) + + expect(operation.protocolVersion).toBe(13) + expect(operation.fallbackAddress).toBe(platformAddress) + expect(operation.balanceCredits).toBe('4000000000') + expect(operation.feeCredits).toBe('111') + expect((await stored(operation.id)).stateTransition).toBe('deadbeef') + expect(sdk.shielded.createStateTransition).toHaveBeenCalledTimes(1) + // Proving is expensive: a repeated prepare returns the stored proof. + await prepare(shieldedRequest()) + expect(sdk.shielded.createStateTransition).toHaveBeenCalledTimes(1) + }) + + test('refuses a denomination the protocol does not allow', async () => { + await expect(prepare(shieldedRequest({ amountCredits: '1234500000' }))) + .rejects.toThrow(/denomination or protocol/) + expect(sdk.shielded.createStateTransition).not.toHaveBeenCalled() + }) + + test('refuses a shielded top-up until the protocol supports it', async () => { + await expect(prepare(shieldedRequest({ kind: 'topUp', identityId }))) + .rejects.toThrow(/protocol v14/) + }) + + test('refuses notes spread over more than the action limit allows', async () => { + const many = Array.from({ length: 8 }, () => shieldedNote(500000000n)) + jest.spyOn(service, 'loadShieldedNotes' as any).mockResolvedValue({ allNotes: ['note'], unspent: many }) + + await expect(prepare(shieldedRequest())).rejects.toThrow(/note action limit/) + }) + + test('ends a shielded registration that created no identity, pointing at the fallback address', async () => { + jest.spyOn(service, 'loadShieldedNotes' as any).mockResolvedValue({ allNotes: ['note'], unspent: [shieldedNote(4000000000n)] }) + // The stored bytes are a mocked proof, so parsing them back is mocked too; + // what matters here is the outcome Platform reports. + jest.spyOn(StateTransitionWASM, 'fromHex').mockReturnValue({ hash: () => 'shielded-transition-hash', hex: () => 'deadbeef' } as any) + + request = shieldedRequest() + await prepare(request) + + const result = await execute() + + expect(result.status).toBe('failed') + expect(result.error).toContain(platformAddress) + // Nothing is left to retry: the denomination went back to that address. + expect(result.identityId).toBeUndefined() + }) + + test('holds the notes against other shielded spends while the operation is pending', async () => { + jest.spyOn(service, 'loadShieldedNotes' as any).mockResolvedValue({ allNotes: ['note'], unspent: [shieldedNote(4000000000n)] }) + await prepare(shieldedRequest()) + const operations = await service.repository(scope).getAll() + + for (const method of [MessagingMethods.SEND_SHIELDED_TRANSFER, MessagingMethods.UNSHIELD_TO_ADDRESS, MessagingMethods.WITHDRAW_SHIELDED_TO_CORE]) { + expect(findConflictingFunding(method, operations)?.source).toBe('shielded') + } + // A Platform spend draws on other funds. + expect(findConflictingFunding(MessagingMethods.SEND_PLATFORM_TRANSFER, operations)).toBeUndefined() + }) + + test('reports the pool denominations, and its balance only when a password is given', async () => { + jest.spyOn(service, 'loadShieldedNotes' as any).mockResolvedValue({ allNotes: ['note'], unspent: [shieldedNote(4000000000n)] }) + + const anonymous = await sources() + expect(anonymous.shielded.protocolVersion).toBe(13) + expect(anonymous.shielded.denominations).toContain('3000000000') + expect(anonymous.shielded.balanceCredits).toBeUndefined() + expect(anonymous.shielded.topUpError).toMatch(/protocol v14/) + + const withPassword = await sources({ ...scope, password }) + expect(withPassword.shielded.balanceCredits).toBe('4000000000') + }) + + test('says the pool is unsupported on a protocol this SDK does not know', async () => { + ;(sdk.node.status as jest.Mock).mockResolvedValue({ version: { protocol: { drive: { current: 99 } } } }) + + const result = await sources() + + expect(result.shielded.denominations).toEqual([]) + expect(result.shielded.error).toContain('protocol 99') + }) })