From 40db8e0380ba119e27ddaa9573b149d2b81bf9d8 Mon Sep 17 00:00:00 2001 From: owl352 Date: Sat, 29 Aug 2026 12:03:48 +0300 Subject: [PATCH 01/31] implement method coreFeeDuffsFor and use it --- package.json | 4 +- src/main/platform/types/messages.ts | 2 + src/main/src/WalletBackend.ts | 2 +- src/main/src/constants/chain.ts | 3 +- src/main/src/services/core/CoreLockService.ts | 8 +- .../services/core/CoreTransactionService.ts | 20 +++-- .../src/services/platform/ShieldedService.ts | 1 + src/main/src/services/wallet/FeeService.ts | 46 +++++++++-- src/main/src/services/wallet/WalletService.ts | 8 +- src/main/src/types/CoinSelection.ts | 6 +- src/main/src/types/CoreTransaction.ts | 4 +- src/main/src/types/Fee.ts | 7 +- src/main/src/utils/coinSelection.ts | 31 +++++-- src/main/src/utils/coreFeeRate.ts | 36 +++++++-- src/main/src/utils/transferInputs.ts | 45 ++++++----- src/preload/index.d.ts | 2 +- src/renderer/src/api/types.ts | 15 ++-- .../pages/identities/Registration.tsx | 12 +-- .../components/pages/transfer/TransferHub.tsx | 23 ++++-- src/renderer/src/constants/sendPages.ts | 2 +- src/renderer/src/hooks/useOperationFee.ts | 8 +- src/renderer/src/utils/amountValidation.ts | 7 +- .../src/utils/identityRegistration.ts | 12 +-- tests/unit/amountValidation.test.ts | 30 ++++--- tests/unit/coinSelection.test.ts | 74 ++++++++++++----- tests/unit/coreTransaction.test.ts | 78 ++++++++++++++++++ tests/unit/estimateFee.test.ts | 81 ++++++++++++++++--- tests/unit/identityRegistrationAmount.test.ts | 21 +++-- tests/unit/transferInputs.test.ts | 4 +- yarn.lock | 14 ++-- 30 files changed, 457 insertions(+), 149 deletions(-) diff --git a/package.json b/package.json index 30bcb364..7f2237a7 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@scure/bip39": "^2.0.1", "class-variance-authority": "0.7.1", "classic-level": "^3.0.0", - "dash-core-p2p": "https://github.com/pshenmic/dash-core-p2p#542a9d9e250c898e8b7a9ed2781cccaba52739f0", + "dash-core-p2p": "https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850", "crypto-toothpick": "https://github.com/owl352/crypto-toothpick#900da4edf5df12eef28cfcb38059c4b69c8f4f77", - "dash-core-sdk": "1.1.3-dev.4", + "dash-core-sdk": "1.1.3-dev.5", "dash-platform-sdk": "1.5.0-dev.9", "dash-ui-kit": "1.0.94", "electron-log": "^5.4.4", diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index 15d90502..8ac7d8d0 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -131,6 +131,8 @@ export interface FeeParams { // identity or a Core address. A list only where an operation pays several, // because each extra output costs the same again. recipient: string | string[] + // L1 quotes only: the fee scales with the inputs the amount takes. + amountDuffs?: bigint | null // Optional because most operations read none of them, and a caller spelling // out which fields it does not use says nothing about the fee. sourceAddress?: string | null diff --git a/src/main/src/WalletBackend.ts b/src/main/src/WalletBackend.ts index d1a159ec..51f182b9 100644 --- a/src/main/src/WalletBackend.ts +++ b/src/main/src/WalletBackend.ts @@ -250,7 +250,7 @@ export class WalletBackend { this.assetLockService = new AssetLockService(walletDAO, new AssetLockDAO(knex), this.coreLockService, this.platformWorkerService) this.shieldedService = new ShieldedService(walletDAO, identityDAO, new ShieldedNoteDAO(knex), new ShieldedPoolDAO(knex), shieldedAddressDAO, this.platformWorkerService, this.assetLockService, preferences) this.platformAddressService = new PlatformAddressService(walletDAO, new PlatformAddressDAO(knex), this.platformWorkerService) - this.feeService = new FeeService(walletDAO, this.platformAddressService, this.platformWorkerService, this.shieldedService, preferences) + this.feeService = new FeeService(walletDAO, addressDAO, this.platformAddressService, this.platformWorkerService, this.shieldedService, providers, preferences) this.identityRegistrationService = new IdentityRegistrationService(walletDAO, identityDAO, this.assetLockService, this.platformWorkerService, this.coreLockService, this.feeService) this.platformTransferService = new PlatformTransferService(walletDAO, identityDAO, this.assetLockService, this.platformAddressService, this.platformWorkerService, this.shieldedService, this.feeService, preferences) this.walletDAO = walletDAO diff --git a/src/main/src/constants/chain.ts b/src/main/src/constants/chain.ts index 5141b3d8..f3a52bec 100644 --- a/src/main/src/constants/chain.ts +++ b/src/main/src/constants/chain.ts @@ -1,9 +1,10 @@ export const SEQUENCE_FINAL = 0xffffffff export const DUFFS_PER_DASH = 100_000_000n -export const CORE_TRANSFER_FEE_DUFFS = 10_000n export const CORE_FEE_PER_BYTE = 1 +export const DUST_THRESHOLD_DUFFS = 546n + // A coinbase input names no parent transaction. export const COINBASE_PREV_TXID = '0'.repeat(64) diff --git a/src/main/src/services/core/CoreLockService.ts b/src/main/src/services/core/CoreLockService.ts index 6be58ecf..da1298c9 100644 --- a/src/main/src/services/core/CoreLockService.ts +++ b/src/main/src/services/core/CoreLockService.ts @@ -6,7 +6,7 @@ import {Network} from '../../types/Network' import {Transaction} from '../../types/Transaction' import {TxLockStatus} from '../../types/TxLockStatus' import {pickCreditChangeAddress, selectTransferInputs} from '../../utils/transferInputs' -import {coreFeeDuffs} from '../../utils/coreFeeRate' +import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {Preferences} from '../../preferences' import {requireWallet} from '../../utils/requireWallet' import {CoreTransactionService} from './CoreTransactionService' @@ -51,12 +51,13 @@ export class CoreLockService implements AssetLockFunder { const grouped = await this.addressDAO.getAddressesByWalletId(walletId) const provider = this.providers.forWallet(walletId, network) await provider.ensureReady() - const {transferInputs, inputTotal, changeAddress} = + const {coreFeeMultiplier} = this.preferences.general + const {transferInputs, inputTotal, changeAddress, feeDuffs} = selectTransferInputs( grouped, await provider.getWalletUtxos(), amountDuffs, - coreFeeDuffs(this.preferences.general.coreFeeMultiplier), + inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true), ) const creditTarget = credit ?? pickCreditChangeAddress(grouped, changeAddress) @@ -67,6 +68,7 @@ export class CoreLockService implements AssetLockFunder { creditAddress: creditTarget.address, changeAddress, inputTotal, + feeDuffs, seed, network, }) diff --git a/src/main/src/services/core/CoreTransactionService.ts b/src/main/src/services/core/CoreTransactionService.ts index 7db9102b..6b50a1ed 100644 --- a/src/main/src/services/core/CoreTransactionService.ts +++ b/src/main/src/services/core/CoreTransactionService.ts @@ -11,7 +11,7 @@ import {Base58Check} from 'dash-core-sdk/src/base58check.js' import {KeyPairController} from 'dash-platform-sdk/src/keyPair/index.js' import {Network} from '../../types/Network' import {ADDRESS_DECODED_LENGTH, ADDRESS_PREFIX} from '../../constants/addresses' -import {SEQUENCE_FINAL} from '../../constants/chain' +import {DUST_THRESHOLD_DUFFS, SEQUENCE_FINAL} from '../../constants/chain' import {BuildSignedTransferParams, RecipientType, TransferInput} from '../../types/CoreTransaction' import {buildAssetLockOutputs} from '../../utils/assetLockTx' @@ -60,10 +60,11 @@ export class CoreTransactionService { creditAddress: string changeAddress: string inputTotal: bigint + feeDuffs: bigint seed: Uint8Array network: Network }): Promise { - const {inputs, amountDuffs, creditAddress, changeAddress, inputTotal, seed, network} = params + const {inputs, amountDuffs, creditAddress, changeAddress, inputTotal, feeDuffs, seed, network} = params const {burnOutput, extraPayload} = buildAssetLockOutputs(amountDuffs, creditAddress) const transaction = new SDKTransaction(undefined, undefined, undefined, 3, TransactionType.TRANSACTION_ASSET_LOCK, extraPayload) @@ -71,14 +72,23 @@ export class CoreTransactionService { const privateKeys = await this.addSignableInputs(transaction, inputs, seed, network) transaction.addOutput(burnOutput) - transaction.generateChange(changeAddress, inputTotal) + this.addChange(transaction, inputTotal - amountDuffs - feeDuffs, changeAddress) transaction.sign(privateKeys) return transaction } + private addChange(transaction: SDKTransaction, change: bigint, changeAddress: string): void { + if (change < 0n) { + throw new Error('Selected inputs do not cover the amount and network fee') + } + if (change >= DUST_THRESHOLD_DUFFS) { + transaction.addOutput(Output.createP2PKH(change, changeAddress)) + } + } + async buildSignedTransfer(params: BuildSignedTransferParams): Promise { - const {inputs, toAddress, recipientType, amount, changeAddress, inputTotal, seed, network} = params + const {inputs, toAddress, recipientType, amount, changeAddress, inputTotal, feeDuffs, seed, network} = params const transaction = new SDKTransaction() const privateKeys = await this.addSignableInputs(transaction, inputs, seed, network) @@ -90,7 +100,7 @@ export class CoreTransactionService { recipientOutput.generateP2PKH(toAddress) } transaction.addOutput(recipientOutput) - transaction.generateChange(changeAddress, inputTotal) + this.addChange(transaction, inputTotal - amount - feeDuffs, changeAddress) transaction.sign(privateKeys) return transaction diff --git a/src/main/src/services/platform/ShieldedService.ts b/src/main/src/services/platform/ShieldedService.ts index eafcddb8..33938f3d 100644 --- a/src/main/src/services/platform/ShieldedService.ts +++ b/src/main/src/services/platform/ShieldedService.ts @@ -651,6 +651,7 @@ export class ShieldedService { return { feeCredits: selection?.feeCredits ?? feeForCount(1), feeDuffs: null, + maxDuffs: null, maxPerTx: maxSpendableCredits(candidates, curve.length, feeForCount), noteLimit: curve.length, } diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index 80034672..734a835b 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -1,4 +1,6 @@ +import {AddressDAO} from '../../database/AddressDAO' import {WalletDAO} from '../../database/WalletDAO' +import {WalletProviderFactory} from '../../providers/WalletProviderFactory' import {PlatformAddressService} from '../platform/PlatformAddressService' import {PlatformWorkerService} from '../platform/PlatformWorkerService' import {ShieldedService} from '../platform/ShieldedService' @@ -13,8 +15,10 @@ import { TransitionFeeOperation, } from '../../../platform/types/messages' import {requireWallet} from '../../utils/requireWallet' +import {maxSelectableAmount, selectCoins} from '../../utils/coinSelection' import {selectPlatformInputsWithFee} from '../../utils/platformTransfer' -import {coreFeeDuffs, coreFeePerByte} from '../../utils/coreFeeRate' +import {coreFeeDuffsFor, coreFeePerByte} from '../../utils/coreFeeRate' +import {selectableTransferUtxos} from '../../utils/transferInputs' // Every fee a quote can be asked for, L1 and L2, is answered here. // @@ -31,22 +35,28 @@ import {coreFeeDuffs, coreFeePerByte} from '../../utils/coreFeeRate' // charged, so there is still only one place it is computed. export class FeeService { private walletDAO: WalletDAO + private addressDAO: AddressDAO private addresses: PlatformAddressService private platform: PlatformWorkerService private shielded: ShieldedService + private providers: WalletProviderFactory private preferences: Preferences constructor( walletDAO: WalletDAO, + addressDAO: AddressDAO, addresses: PlatformAddressService, platform: PlatformWorkerService, shielded: ShieldedService, + providers: WalletProviderFactory, preferences: Preferences, ) { this.walletDAO = walletDAO + this.addressDAO = addressDAO this.addresses = addresses this.platform = platform this.shielded = shielded + this.providers = providers this.preferences = preferences } @@ -54,12 +64,12 @@ export class FeeService { // how it is priced; what that price is belongs to the worker, not here. async estimateFee(walletId: string, operation: FeeOperation, params: FeeParams): Promise { const wallet = await requireWallet(this.walletDAO, walletId) - const {coreFeeMultiplier} = this.preferences.general switch (operation) { - // Paid in Dash on L1: a flat per-transaction rate. + // Paid in Dash on L1, per byte, so the quote runs the selection the send + // will run rather than a floor the send is free to exceed. case 'coreSend': - return {feeCredits: null, feeDuffs: coreFeeDuffs(coreFeeMultiplier), maxPerTx: null, noteLimit: null} + return {feeCredits: null, ...await this.coreQuote(wallet, params), maxPerTx: null, noteLimit: null} // Two transactions, so two fees. The L1 lock is paid in Dash on top of the // amount; the transition its proof funds is paid in credits out of what @@ -70,7 +80,7 @@ export class FeeService { case 'identityTopUpL1': return { feeCredits: await this.protocolFee(wallet, operation, params, 1), - feeDuffs: coreFeeDuffs(coreFeeMultiplier), + ...await this.coreQuote(wallet, params), maxPerTx: null, noteLimit: null, } @@ -158,7 +168,31 @@ export class FeeService { return plan?.feeCredits ?? this.protocolFee(wallet, operation, params, 1) } + // The fee scales with the inputs the selection takes, so the quote runs that + // selection over the same coins the send will. maxDuffs is what those coins + // can fund at their own price, which is the only amount a Max can offer + // without the send refusing it. + private async coreQuote(wallet: Wallet, params: FeeParams): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { + const feeForInputs = (inputsCount: number): bigint => + coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, 1, true) + + const grouped = await this.addressDAO.getAddressesByWalletId(wallet.walletId) + const utxos = await this.providers.forWallet(wallet.walletId, wallet.network).getWalletUtxos() + const selectable = selectableTransferUtxos(grouped, utxos, params.sourceAddress ?? undefined) + + const maxDuffs = maxSelectableAmount(selectable, feeForInputs) + const amountDuffs = params.amountDuffs ?? 0n + + // A quote is asked for before the amount is affordable, so one the selection + // would refuse answers with the one-input floor rather than failing. + const feeDuffs = amountDuffs > 0n && amountDuffs <= maxDuffs + ? selectCoins(selectable, amountDuffs, feeForInputs).fee + : feeForInputs(1) + + return {feeDuffs, maxDuffs} + } + private credits(feeCredits: bigint | null): OperationFee { - return {feeCredits, feeDuffs: null, maxPerTx: null, noteLimit: null} + return {feeCredits, feeDuffs: null, maxDuffs: null, maxPerTx: null, noteLimit: null} } } diff --git a/src/main/src/services/wallet/WalletService.ts b/src/main/src/services/wallet/WalletService.ts index d5f53129..23c30036 100644 --- a/src/main/src/services/wallet/WalletService.ts +++ b/src/main/src/services/wallet/WalletService.ts @@ -26,7 +26,7 @@ import { IDENTITY_SCAN_LIMIT, PLATFORM_ACCOUNT, } from '../../constants/addresses' -import {coreFeeDuffs} from '../../utils/coreFeeRate' +import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {identityPath} from '../../utils/identityKeys' import {coreAccountPath, coreAddressDeriver} from "../../utils/addressDiscovery"; import {selectTransferInputs} from '../../utils/transferInputs' @@ -320,12 +320,13 @@ export class WalletService { const grouped = await this.addressDAO.getAddressesByWalletId(walletId) const provider = this.providers.forWallet(walletId, network) await provider.ensureReady() - const {transferInputs, inputTotal, changeAddress} = + const {coreFeeMultiplier} = this.preferences.general + const {transferInputs, inputTotal, changeAddress, feeDuffs} = selectTransferInputs( grouped, await provider.getWalletUtxos(), amountDuffs, - coreFeeDuffs(this.preferences.general.coreFeeMultiplier), + inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true), fromAddress, ) @@ -336,6 +337,7 @@ export class WalletService { amount: amountDuffs, changeAddress, inputTotal, + feeDuffs, seed, network, }) diff --git a/src/main/src/types/CoinSelection.ts b/src/main/src/types/CoinSelection.ts index e4ebfb60..cda600b4 100644 --- a/src/main/src/types/CoinSelection.ts +++ b/src/main/src/types/CoinSelection.ts @@ -12,6 +12,6 @@ export interface CoinSelectionResult { change: bigint } -export interface CoinSelectionParams { - fee: bigint -} +// The count is only known once the selection stops, so what crosses is the +// price of a count rather than a price. +export type CoreFeeForInputs = (inputsCount: number) => bigint diff --git a/src/main/src/types/CoreTransaction.ts b/src/main/src/types/CoreTransaction.ts index 86bfd941..713ec840 100644 --- a/src/main/src/types/CoreTransaction.ts +++ b/src/main/src/types/CoreTransaction.ts @@ -15,6 +15,7 @@ export interface TransferInputSelection { transferInputs: TransferInput[] inputTotal: bigint changeAddress: string + feeDuffs: bigint } export interface BuildSignedTransferParams { @@ -24,6 +25,7 @@ export interface BuildSignedTransferParams { amount: bigint changeAddress: string inputTotal: bigint + feeDuffs: bigint seed: Uint8Array network: Network -} \ No newline at end of file +} diff --git a/src/main/src/types/Fee.ts b/src/main/src/types/Fee.ts index 349d3e96..bcc2ec4a 100644 --- a/src/main/src/types/Fee.ts +++ b/src/main/src/types/Fee.ts @@ -1,11 +1,14 @@ // feeDuffs is what L1 charges on top of the amount, feeCredits what L2 takes // out of it. An L1 -> L2 transfer is two transactions and carries both; every // other operation carries one, and null means it cannot be priced yet — no -// identity picked, no amount typed. maxPerTx and noteLimit are pool-spend -// facts: nothing else is capped by anything but the balance. +// identity picked, no amount typed. maxDuffs is the largest amount the L1 +// selection can fund, which is not the balance minus feeDuffs: the fee grows +// with every input it takes. maxPerTx and noteLimit are pool-spend facts: +// nothing else is capped by anything but the balance. export interface OperationFee { feeCredits: bigint | null feeDuffs: bigint | null + maxDuffs: bigint | null maxPerTx: bigint | null noteLimit: number | null } diff --git a/src/main/src/utils/coinSelection.ts b/src/main/src/utils/coinSelection.ts index 328927ef..a2c8b888 100644 --- a/src/main/src/utils/coinSelection.ts +++ b/src/main/src/utils/coinSelection.ts @@ -1,15 +1,18 @@ -import {CoinSelectionParams, CoinSelectionResult, SelectableUtxo} from '../types/CoinSelection' +import {CoinSelectionResult, CoreFeeForInputs, SelectableUtxo} from '../types/CoinSelection' + +const bySatoshisDesc = (a: SelectableUtxo, b: SelectableUtxo): number => + a.satoshis < b.satoshis ? 1 : a.satoshis > b.satoshis ? -1 : 0 export function selectCoins( utxos: SelectableUtxo[], target: bigint, - params: CoinSelectionParams, + feeForInputs: CoreFeeForInputs, ): CoinSelectionResult { if (target <= 0n) { throw new Error('Send amount must be greater than zero') } - const sorted = [...utxos].sort((a, b) => (a.satoshis < b.satoshis ? 1 : a.satoshis > b.satoshis ? -1 : 0)) + const sorted = [...utxos].sort(bySatoshisDesc) const selected: SelectableUtxo[] = [] let inputTotal = 0n @@ -18,11 +21,29 @@ export function selectCoins( selected.push(utxo) inputTotal += utxo.satoshis - const change = inputTotal - target - params.fee + const fee = feeForInputs(selected.length) + const change = inputTotal - target - fee if (change >= 0n) { - return { inputs: selected, inputTotal, fee: params.fee, change } + return { inputs: selected, inputTotal, fee, change } } } throw new Error('Insufficient funds to cover amount and network fee') } + +// Not the balance minus a fee: an input worth less than what it adds to the fee +// leaves the set able to send less, so the answer is the best prefix. +export function maxSelectableAmount(utxos: SelectableUtxo[], feeForInputs: CoreFeeForInputs): bigint { + const sorted = [...utxos].sort(bySatoshisDesc) + + let inputTotal = 0n + let max = 0n + + sorted.forEach((utxo, index) => { + inputTotal += utxo.satoshis + const spendable = inputTotal - feeForInputs(index + 1) + if (spendable > max) max = spendable + }) + + return max +} diff --git a/src/main/src/utils/coreFeeRate.ts b/src/main/src/utils/coreFeeRate.ts index c5e7f081..23022be6 100644 --- a/src/main/src/utils/coreFeeRate.ts +++ b/src/main/src/utils/coreFeeRate.ts @@ -1,4 +1,5 @@ -import {CORE_FEE_PER_BYTE, CORE_TRANSFER_FEE_DUFFS} from '../constants/chain' +import {Input, Output, Script, Transaction} from 'dash-core-sdk' +import {CORE_FEE_PER_BYTE} from '../constants/chain' // Consensus rejects a withdrawal whose coreFeePerByte is not a non-zero // Fibonacci number, so a multiplied rate has to be snapped onto the sequence. @@ -16,8 +17,33 @@ export function coreFeePerByte(multiplier: number): number { return rate } -// What an L1 transaction pays, at the user's multiplier. Written once so the -// fee a send charges and the fee its quote shows cannot drift. -export function coreFeeDuffs(multiplier: number): bigint { - return CORE_TRANSFER_FEE_DUFFS * BigInt(multiplier) +export function coreFeeDuffsFor(multiplier: number, inputsCount: number, outputsCount: number, withChange: boolean): bigint { + const feePerByte = coreFeePerByte(multiplier) + + // dummy script which bigger than 99% of sigs + const dummyScript = new Script(`OP_PUSHDATA1 ${'0'.repeat(288)}`) + + const dummyInputs: Input[] = [] + const dummyOutputs: Output[] = [] + + for(let i = 0; i < inputsCount; i++) { + dummyInputs.push(new Input('0'.repeat(64), 1, dummyScript, 0)) + } + + for(let i = 0; i < outputsCount; i++) { + dummyOutputs.push(new Output(1n, dummyScript)) + } + + const tx = new Transaction( + dummyInputs, + dummyOutputs, + 0, + 0, + ) + + if(withChange) { + tx.generateChange('111111111111111111111111133izVn', BigInt(tx.bytes().length)*4n) + } + + return BigInt(tx.bytes().length * feePerByte) } diff --git a/src/main/src/utils/transferInputs.ts b/src/main/src/utils/transferInputs.ts index 54c2f4bb..816c8c29 100644 --- a/src/main/src/utils/transferInputs.ts +++ b/src/main/src/utils/transferInputs.ts @@ -1,5 +1,5 @@ import {GroupedAddresses} from '../types/GroupedAddresses' -import {SelectableUtxo} from '../types/CoinSelection' +import {CoreFeeForInputs, SelectableUtxo} from '../types/CoinSelection' import {TransferInput, TransferInputSelection} from '../types/CoreTransaction' import {UTXO} from '../types/UTXO' import {selectCoins} from './coinSelection' @@ -29,36 +29,45 @@ export function pickCreditChangeAddress( return {address: credit.address, derivationPath: credit.derivationPath} } +// The provider answers for the whole wallet, including indexes discovery has +// not derived — those have no derivation path and cannot be signed. +export function selectableTransferUtxos( + grouped: GroupedAddresses, + utxos: UTXO[], + fromAddress?: string, +): SelectableUtxo[] { + const owned = new Set([...grouped.receiving, ...grouped.change].map(a => a.address)) + + return utxos + .filter(utxo => owned.has(utxo.address)) + .filter(utxo => fromAddress == null || utxo.address === fromAddress) + .map(utxo => ({ + txid: utxo.txId, + vout: utxo.vOut, + satoshis: utxo.satoshis, + address: utxo.address, + })) +} + export function selectTransferInputs( grouped: GroupedAddresses, utxos: UTXO[], amountDuffs: bigint, - feeDuffs: bigint, + feeForInputs: CoreFeeForInputs, fromAddress?: string, ): TransferInputSelection { const pathByAddress = new Map( [...grouped.receiving, ...grouped.change].map(a => [a.address, a.derivationPath]), ) - // The provider answers for the whole wallet, including indexes discovery has - // not derived — those have no derivation path and cannot be signed. - const ownedUtxos = utxos - .filter(utxo => pathByAddress.has(utxo.address)) - .filter(utxo => fromAddress == null || utxo.address === fromAddress) + const selectable = selectableTransferUtxos(grouped, utxos, fromAddress) - if (ownedUtxos.length === 0) { + if (selectable.length === 0) { throw new Error('No spendable funds in this wallet') } - const selectable: SelectableUtxo[] = ownedUtxos.map(utxo => ({ - txid: utxo.txId, - vout: utxo.vOut, - satoshis: utxo.satoshis, - address: utxo.address, - })) - - const selection = selectCoins(selectable, amountDuffs, {fee: feeDuffs}) - const utxoByKey = new Map(ownedUtxos.map(u => [`${u.txId}:${u.vOut}`, u])) + const selection = selectCoins(selectable, amountDuffs, feeForInputs) + const utxoByKey = new Map(utxos.map(u => [`${u.txId}:${u.vOut}`, u])) const transferInputs: TransferInput[] = selection.inputs.map(input => { const owned = utxoByKey.get(`${input.txid}:${input.vout}`) @@ -76,5 +85,5 @@ export function selectTransferInputs( } }) - return {transferInputs, inputTotal: selection.inputTotal, changeAddress: pickChangeAddress(grouped)} + return {transferInputs, inputTotal: selection.inputTotal, changeAddress: pickChangeAddress(grouped), feeDuffs: selection.fee} } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4b229506..d41b93eb 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -76,7 +76,7 @@ declare global { setWalletLabel: (walletId: string, label: string | null) => Promise sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, fromAddress?: string) => Promise getTxLockStatus: (walletId: string, txid: string) => Promise - estimateFee: (walletId: string, operation: string, params: unknown) => Promise<{ feeCredits: bigint | null; feeDuffs: bigint | null; maxPerTx: bigint | null; noteLimit: number | null }> + estimateFee: (walletId: string, operation: string, params: unknown) => Promise<{ feeCredits: bigint | null; feeDuffs: bigint | null; maxDuffs: bigint | null; maxPerTx: bigint | null; noteLimit: number | null }> sendPlatformTransfer: (walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string) => Promise topUpIdentityFromAddresses: (walletId: string, identityId: string, fromAddress: string | null, amountCredits: bigint, password: string) => Promise withdrawPlatformCredits: (walletId: string, fromAddress: string | null, toCoreAddress: string, amountCredits: bigint, password: string) => Promise diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index 7d23a90f..8d2ea57c 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -53,6 +53,8 @@ export interface FeeParams { // Whatever kind of address this operation pays. The transfer screens pay one, // so they never need the list form. recipient: string | string[] + // L1 quotes only: the fee scales with the inputs the amount takes. + amountDuffs?: bigint | null // Optional because most operations read none of them. sourceAddress?: string | null identityId?: string | null @@ -63,11 +65,14 @@ export interface FeeParams { // feeDuffs is what L1 charges on top of the amount, feeCredits what L2 takes // out of it. An L1 -> L2 transfer is two transactions and carries both; every // other operation carries one, and null means it cannot be priced yet. -// maxPerTx and noteLimit are pool-spend facts: nothing else is capped by -// anything but the balance. +// maxDuffs is the largest amount the L1 selection can fund, which is not the +// balance minus feeDuffs: the fee grows with every input it takes. maxPerTx and +// noteLimit are pool-spend facts: nothing else is capped by anything but the +// balance. export interface OperationFee { feeCredits: bigint | null feeDuffs: bigint | null + maxDuffs: bigint | null maxPerTx: bigint | null noteLimit: number | null } @@ -79,12 +84,10 @@ export interface OperationFeeParams extends FeeParams { export interface AmountValidationParams { isCoreOperation: boolean amount: string - // Every fee the send pays in Dash. An L1 -> L2 transfer locks the L2 fee too, - // so the amount asked for is the amount that arrives. - totalFeeDuffs: bigint + // Null while the quote that knows it is in flight. + coreMaxDuffs: bigint | null operation: TransferOperation | null amountDuffs: bigint - balanceDuffs: bigint amountCredits: bigint minCredits: bigint availableCredits: bigint | null diff --git a/src/renderer/src/components/pages/identities/Registration.tsx b/src/renderer/src/components/pages/identities/Registration.tsx index e9125a89..8e4dbab3 100644 --- a/src/renderer/src/components/pages/identities/Registration.tsx +++ b/src/renderer/src/components/pages/identities/Registration.tsx @@ -128,10 +128,11 @@ export default function IdentityRegistration(): React.JSX.Element { ? shieldedBalance : null - const { feeCredits, feeDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeError } = useOperationFee(walletId, operation, { + const { feeCredits, feeDuffs, maxDuffs: coreSelectableDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeError } = useOperationFee(walletId, operation, { destinationValid: true, recipient: '', amountCredits, + amountDuffs: fromKind === SourceKind.Core ? amountDuffs : null, sourceAddress: selectedSource?.platformAddress ?? null, identityId: null, noteIndexes: null, @@ -140,7 +141,9 @@ export default function IdentityRegistration(): React.JSX.Element { // The Core fee is paid on top of the amount, and an L1 registration locks the // identity-create fee on top of that so the amount typed is what is credited. const totalFeeDuffs = feeDuffs === null ? 0n : feeDuffs + creditsToDuffs(feeCredits ?? 0n) - const coreMaxDuffs = identityRegistrationMaxDuffs(balanceDuffs, totalFeeDuffs) + const coreMaxDuffs = coreSelectableDuffs === null + ? null + : identityRegistrationMaxDuffs(coreSelectableDuffs, creditsToDuffs(feeCredits ?? 0n)) const platformMaxDuffs = maxPerTx !== null ? creditsToDuffs(maxPerTx > 0n ? maxPerTx : 0n) : feeCredits !== null && availableCredits !== null @@ -148,14 +151,13 @@ export default function IdentityRegistration(): React.JSX.Element { : null const maxDuffs = fromKind === SourceKind.Core ? coreMaxDuffs : platformMaxDuffs const amountError = fromKind === SourceKind.Core - ? identityRegistrationAmountError(amount, amountDuffs, balanceDuffs, totalFeeDuffs) + ? identityRegistrationAmountError(amount, amountDuffs, coreMaxDuffs) : amountErrorFor({ isCoreOperation: false, amount, - totalFeeDuffs, + coreMaxDuffs, operation, amountDuffs, - balanceDuffs, amountCredits, minCredits: info.minCredits ?? 0n, availableCredits, diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 7d98606b..b2263ad2 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -237,11 +237,12 @@ function WalletTransferHub(): React.JSX.Element { : toKind === DestinationKind.NewIdentity ? true : isLikelyShieldedAddress(trimmedTo) - const { feeCredits, feeDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr } = useOperationFee(walletId, operation, { + const { feeCredits, feeDuffs, maxDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr } = useOperationFee(walletId, operation, { destinationValid, recipient: trimmedTo, amountCredits, - sourceAddress: selectedSource?.platformAddress ?? null, + amountDuffs: isCoreOperation ? amountDuffs : null, + sourceAddress: coreSpecificAddress?.address ?? selectedSource?.platformAddress ?? null, identityId: selectedIdentity?.identifier ?? null, noteIndexes: shieldedSpecificNotes?.map(note => note.index) ?? null, }) @@ -250,13 +251,20 @@ function WalletTransferHub(): React.JSX.Element { // L2 fee on top of that, so the amount typed is the amount that arrives. const totalFeeDuffs = feeDuffs === null ? 0n : feeDuffs + creditsToDuffs(feeCredits ?? 0n) + // What the L1 selection can fund, less whatever the operation locks on L2. + const coreMaxDuffs = useMemo((): bigint | null => { + if (maxDuffs === null) return null + const spendable = maxDuffs - creditsToDuffs(feeCredits ?? 0n) + return spendable > 0n ? spendable : 0n + }, [maxDuffs, feeCredits]) + const sliderMaxAmount = useMemo((): bigint | null => { - if (isCoreOperation) return balanceDuffs > totalFeeDuffs ? balanceDuffs - totalFeeDuffs : 0n + if (isCoreOperation) return coreMaxDuffs if (maxPerTx !== null) return creditsToDuffs(maxPerTx > 0n ? maxPerTx : 0n) if (availableCredits === null || feeCredits === null) return null const spendable = availableCredits - feeCredits return creditsToDuffs(spendable > 0n ? spendable : 0n) - }, [isCoreOperation, balanceDuffs, maxPerTx, availableCredits, feeCredits]) + }, [isCoreOperation, coreMaxDuffs, maxPerTx, availableCredits, feeCredits]) const sliderPercent = useMemo(() => { if (sliderMaxAmount === null || sliderMaxAmount === 0n) return 0 @@ -298,7 +306,7 @@ function WalletTransferHub(): React.JSX.Element { const routeReady = operation != null && sourceReady && destinationReady && !coreSourceGated const amountReady = isCoreOperation - ? amountDuffs > 0n && amountDuffs + totalFeeDuffs <= balanceDuffs + ? amountDuffs > 0n && coreMaxDuffs !== null && amountDuffs <= coreMaxDuffs : amountCredits >= minCredits && amountCredits > 0n && feeCredits !== null && availableCredits !== null && amountCredits + feeCredits <= availableCredits @@ -319,7 +327,7 @@ function WalletTransferHub(): React.JSX.Element { const handleMax = (): void => { if (isCoreOperation) { - setAmount(davToDash(balanceDuffs > totalFeeDuffs ? balanceDuffs - totalFeeDuffs : 0n)) + if (coreMaxDuffs !== null) setAmount(davToDash(coreMaxDuffs)) return } if (maxPerTx !== null) { @@ -340,10 +348,9 @@ function WalletTransferHub(): React.JSX.Element { const amountError = amountErrorFor({ isCoreOperation, amount, - totalFeeDuffs, + coreMaxDuffs, operation, amountDuffs, - balanceDuffs, amountCredits, minCredits, availableCredits, diff --git a/src/renderer/src/constants/sendPages.ts b/src/renderer/src/constants/sendPages.ts index e964d82b..322e31bf 100644 --- a/src/renderer/src/constants/sendPages.ts +++ b/src/renderer/src/constants/sendPages.ts @@ -28,7 +28,7 @@ export const SHIELDED_BALANCE_UNKNOWN_ERROR = 'Shielded balance is unknown — s export const TRANSITION_FEE_ERROR = 'Failed to estimate the network fee' // What an operation reads as before its fee is known. -export const NO_OPERATION_FEE: OperationFee = { feeCredits: null, feeDuffs: null, maxPerTx: null, noteLimit: null } +export const NO_OPERATION_FEE: OperationFee = { feeCredits: null, feeDuffs: null, maxDuffs: null, maxPerTx: null, noteLimit: null } export const sendPageData: TransferPageType = { diff --git a/src/renderer/src/hooks/useOperationFee.ts b/src/renderer/src/hooks/useOperationFee.ts index 0c0210e9..9490dd83 100644 --- a/src/renderer/src/hooks/useOperationFee.ts +++ b/src/renderer/src/hooks/useOperationFee.ts @@ -12,20 +12,20 @@ export function useOperationFee( operation: TransferOperation | null, params: OperationFeeParams, ): OperationFee & { loading: boolean; err: string | null } { - const { destinationValid, amountCredits, recipient, sourceAddress, identityId, noteIndexes } = params + const { destinationValid, amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteIndexes } = params const noteKey = noteIndexes?.join(',') ?? '' const pending = useMemo( () => { if (walletId === null || operation === null || !destinationValid) return null - const feeParams = { amountCredits, recipient, sourceAddress, identityId, noteIndexes } - return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${recipient}:${sourceAddress}:${identityId}:${noteKey}` } + const feeParams = { amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteIndexes } + return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${sourceAddress}:${identityId}:${noteKey}` } }, // noteIndexes is keyed by noteKey: a fresh array of the same indexes is the // same quote, and re-running on identity would re-ask on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - [walletId, operation, destinationValid, amountCredits, recipient, sourceAddress, identityId, noteKey], + [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteKey], ) const [settled, setSettled] = useState(null) diff --git a/src/renderer/src/utils/amountValidation.ts b/src/renderer/src/utils/amountValidation.ts index e4798ae2..5cbf7fea 100644 --- a/src/renderer/src/utils/amountValidation.ts +++ b/src/renderer/src/utils/amountValidation.ts @@ -5,14 +5,13 @@ import { creditsToDuffs, davToDash } from './balance' import { isPoolIdentityDenomination } from './transferMatrix' export function amountErrorFor(params: AmountValidationParams): string | null { - const { isCoreOperation, amount, operation, amountDuffs, balanceDuffs, totalFeeDuffs, amountCredits, minCredits, availableCredits, feeCredits, maxPerTx, noteLimit } = params + const { isCoreOperation, amount, operation, amountDuffs, coreMaxDuffs, amountCredits, minCredits, availableCredits, feeCredits, maxPerTx, noteLimit } = params if (amount.length === 0) return null if (isCoreOperation) { - if (amountDuffs <= 0n || amountDuffs + totalFeeDuffs <= balanceDuffs) return null - const maxSendableDuffs = balanceDuffs > totalFeeDuffs ? balanceDuffs - totalFeeDuffs : 0n - return `Max sendable is ${davToDash(maxSendableDuffs)} Dash after fees.` + if (amountDuffs <= 0n || coreMaxDuffs === null || amountDuffs <= coreMaxDuffs) return null + return `Max sendable is ${davToDash(coreMaxDuffs)} Dash after fees.` } if (operation === TransferOperation.IdentityCreateFromShielded && !isPoolIdentityDenomination(amountCredits)) { diff --git a/src/renderer/src/utils/identityRegistration.ts b/src/renderer/src/utils/identityRegistration.ts index 626a7d13..2e36bb4c 100644 --- a/src/renderer/src/utils/identityRegistration.ts +++ b/src/renderer/src/utils/identityRegistration.ts @@ -2,15 +2,15 @@ import { AssetLockFundingPhase } from '../enums/AssetLockFundingPhase' import { IDENTITY_REGISTRATION_MIN_DUFFS } from '../constants' import { davToDash } from './balance' -export function identityRegistrationMaxDuffs(balanceDuffs: bigint, totalFeeDuffs: bigint): bigint { - return balanceDuffs > totalFeeDuffs ? balanceDuffs - totalFeeDuffs : 0n +// What the L1 selection can fund, less what the transition takes on L2. +export function identityRegistrationMaxDuffs(coreMaxDuffs: bigint, creditsFeeDuffs: bigint): bigint { + return coreMaxDuffs > creditsFeeDuffs ? coreMaxDuffs - creditsFeeDuffs : 0n } export function identityRegistrationAmountError( amount: string, amountDuffs: bigint, - balanceDuffs: bigint, - totalFeeDuffs: bigint, + maxDuffs: bigint | null, ): string | null { if (amount.length === 0) return null if (!/^(?:\d+(?:\.\d{0,8})?|\.\d{1,8})$/.test(amount)) return 'Enter a valid Dash amount with up to 8 decimal places.' @@ -18,8 +18,8 @@ export function identityRegistrationAmountError( return `Minimum identity funding is ${davToDash(IDENTITY_REGISTRATION_MIN_DUFFS)} Dash.` } - const maxDuffs = identityRegistrationMaxDuffs(balanceDuffs, totalFeeDuffs) - if (amountDuffs > maxDuffs) { + // Null until the quote that priced the selection lands. + if (maxDuffs !== null && amountDuffs > maxDuffs) { return `Max available is ${davToDash(maxDuffs)} Dash after fees.` } diff --git a/tests/unit/amountValidation.test.ts b/tests/unit/amountValidation.test.ts index 28c469d4..a77e2031 100644 --- a/tests/unit/amountValidation.test.ts +++ b/tests/unit/amountValidation.test.ts @@ -8,10 +8,9 @@ function params(overrides: Partial = {}): AmountValidati return { isCoreOperation: false, amount: '0.00001', - totalFeeDuffs: 10_000n, + coreMaxDuffs: null, operation: TransferOperation.AddressFundsTransfer, amountDuffs: 1_000n, - balanceDuffs: 0n, amountCredits: 1_000_000n, minCredits: 500_000n, availableCredits: 900_000_000n, @@ -23,41 +22,52 @@ function params(overrides: Partial = {}): AmountValidati } describe('amountErrorFor', () => { - it('accepts a Dash amount with room for the fixed network fee', () => { + it('accepts a Dash amount the selection can fund', () => { expect(amountErrorFor(params({ isCoreOperation: true, amount: '0.9999', amountDuffs: 99_990_000n, - balanceDuffs: 100_000_000n, + coreMaxDuffs: 99_990_000n, amountCredits: 0n, }))).toBeNull() }) - // An L1 -> L2 transfer locks the L2 fee alongside the amount, so both fees - // reach here already summed into one Dash figure. + // An L1 -> L2 transfer locks the L2 fee alongside the amount, so the ceiling + // reaching here is already net of both. it('reports the max after both fees on an L1 -> L2 transfer', () => { expect(amountErrorFor(params({ isCoreOperation: true, operation: TransferOperation.AssetLockFunding, amount: '1', amountDuffs: 100_000_000n, - balanceDuffs: 100_000_000n, - totalFeeDuffs: 66_000n, + coreMaxDuffs: 99_934_000n, amountCredits: 0n, feeCredits: 56_000_000n, }))).toBe('Max sendable is 0.99934 Dash after fees.') }) - it('reports the max Dash amount after the fixed network fee', () => { + it('reports the max Dash amount the selection can fund', () => { expect(amountErrorFor(params({ isCoreOperation: true, amount: '1', amountDuffs: 100_000_000n, - balanceDuffs: 100_000_000n, + coreMaxDuffs: 99_990_000n, amountCredits: 0n, }))).toBe('Max sendable is 0.9999 Dash after fees.') }) + // The quote that prices the selection is still in flight, and a ceiling + // nobody has drawn yet is not one an amount can be over. + it('holds its verdict on an L1 amount that is not priced yet', () => { + expect(amountErrorFor(params({ + isCoreOperation: true, + amount: '1', + amountDuffs: 100_000_000n, + coreMaxDuffs: null, + amountCredits: 0n, + }))).toBeNull() + }) + it('is silent while nothing has been typed', () => { expect(amountErrorFor(params({amount: '', amountCredits: 0n}))).toBeNull() }) diff --git a/tests/unit/coinSelection.test.ts b/tests/unit/coinSelection.test.ts index 61ddcf11..1601b766 100644 --- a/tests/unit/coinSelection.test.ts +++ b/tests/unit/coinSelection.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest' -import {selectCoins} from '../../src/main/src/utils/coinSelection' +import {maxSelectableAmount, selectCoins} from '../../src/main/src/utils/coinSelection' import {SelectableUtxo} from '../../src/main/src/types/CoinSelection' -import {CORE_TRANSFER_FEE_DUFFS} from '../../src/main/src/constants/chain' +import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' -const PARAMS = {fee: CORE_TRANSFER_FEE_DUFFS} +const FEE = (inputsCount: number): bigint => coreFeeDuffsFor(1, inputsCount, 1, true) const ONE_DASH = 100_000_000n function utxo(satoshis: bigint, n = 0): SelectableUtxo { @@ -12,72 +12,104 @@ function utxo(satoshis: bigint, n = 0): SelectableUtxo { describe('selectCoins', () => { it('selects a single sufficient utxo and returns change', () => { - const res = selectCoins([utxo(ONE_DASH)], ONE_DASH / 2n, PARAMS) + const res = selectCoins([utxo(ONE_DASH)], ONE_DASH / 2n, FEE) expect(res.inputs).toHaveLength(1) expect(res.inputTotal).toBe(ONE_DASH) - expect(res.fee).toBe(PARAMS.fee) + expect(res.fee).toBe(FEE(1)) expect(res.inputTotal).toBe(ONE_DASH / 2n + res.fee + res.change) }) it('accumulates multiple utxos until the target plus fee is covered', () => { const utxos = [utxo(30_000n, 0), utxo(30_000n, 1), utxo(30_000n, 2)] - const res = selectCoins(utxos, 50_000n, PARAMS) + const res = selectCoins(utxos, 50_000n, FEE) expect(res.inputs.length).toBeGreaterThan(1) expect(res.inputTotal).toBe(50_000n + res.fee + res.change) }) it('prefers larger utxos first (fewer inputs)', () => { const utxos = [utxo(10_000n, 0), utxo(ONE_DASH, 1), utxo(10_000n, 2)] - const res = selectCoins(utxos, ONE_DASH / 2n, PARAMS) + const res = selectCoins(utxos, ONE_DASH / 2n, FEE) expect(res.inputs).toHaveLength(1) expect(res.inputs[0].satoshis).toBe(ONE_DASH) }) it('conserves value: inputTotal === target + fee + change', () => { - const res = selectCoins([utxo(5n * ONE_DASH)], 3n * ONE_DASH, PARAMS) + const res = selectCoins([utxo(5n * ONE_DASH)], 3n * ONE_DASH, FEE) expect(res.inputTotal).toBe(3n * ONE_DASH + res.fee + res.change) }) it('throws on zero or negative target', () => { - expect(() => selectCoins([utxo(ONE_DASH)], 0n, PARAMS)).toThrow('greater than zero') - expect(() => selectCoins([utxo(ONE_DASH)], -5n, PARAMS)).toThrow('greater than zero') + expect(() => selectCoins([utxo(ONE_DASH)], 0n, FEE)).toThrow('greater than zero') + expect(() => selectCoins([utxo(ONE_DASH)], -5n, FEE)).toThrow('greater than zero') }) it('throws when funds cannot cover amount + fee', () => { - expect(() => selectCoins([utxo(10_000n)], 50_000n, PARAMS)).toThrow('Insufficient funds') + expect(() => selectCoins([utxo(10_000n)], 50_000n, FEE)).toThrow('Insufficient funds') }) it('throws when funds cover amount but not the fee', () => { - expect(() => selectCoins([utxo(50_500n)], 50_000n, PARAMS)).toThrow('Insufficient funds') + expect(() => selectCoins([utxo(50_000n + FEE(1) - 1n)], 50_000n, FEE)).toThrow('Insufficient funds') }) it('throws on an empty utxo set', () => { - expect(() => selectCoins([], ONE_DASH, PARAMS)).toThrow('Insufficient funds') + expect(() => selectCoins([], ONE_DASH, FEE)).toThrow('Insufficient funds') }) it('uses the fixed Core network fee', () => { - const res = selectCoins([utxo(ONE_DASH)], 1000n, PARAMS) - expect(res.fee).toBe(PARAMS.fee) + const res = selectCoins([utxo(ONE_DASH)], 1000n, FEE) + expect(res.fee).toBe(FEE(1)) }) - it('sends the maximum balance minus the fixed fee with many inputs', () => { + // A flat fee let the selection sign more inputs than it had paid for. + it('charges all 100 inputs when it spends the whole balance', () => { const utxos = Array.from({length: 100}, (_, index) => utxo(20_000n, index)) const balance = utxos.reduce((sum, input) => sum + input.satoshis, 0n) - const res = selectCoins(utxos, balance - PARAMS.fee, PARAMS) + const res = selectCoins(utxos, balance - FEE(100), FEE) expect(res.inputs).toHaveLength(100) expect(res.inputTotal).toBe(balance) - expect(res.fee).toBe(PARAMS.fee) + expect(res.fee).toBe(FEE(100)) + expect(res.fee).toBeGreaterThan(FEE(1)) expect(res.change).toBe(0n) }) it('returns change smaller than the fixed fee', () => { const target = 50_000n - const total = target + PARAMS.fee + 1_500n - const res = selectCoins([utxo(total)], target, PARAMS) + const total = target + FEE(1) + 1_500n + const res = selectCoins([utxo(total)], target, FEE) expect(res.change).toBe(1_500n) - expect(res.fee).toBe(PARAMS.fee) + expect(res.fee).toBe(FEE(1)) expect(res.inputTotal).toBe(target + res.fee + res.change) }) }) + +describe('maxSelectableAmount', () => { + it('answers zero for a wallet with nothing to spend', () => { + expect(maxSelectableAmount([], FEE)).toBe(0n) + expect(maxSelectableAmount([utxo(100n)], FEE)).toBe(0n) + }) + + it('stops before an input worth less than the bytes it adds', () => { + const dust = Array.from({length: 20}, (_, index) => utxo(10n, index + 1)) + const max = maxSelectableAmount([utxo(ONE_DASH, 0), ...dust], FEE) + + expect(max).toBe(ONE_DASH - FEE(1)) + }) + + it('spends every input that pays for itself', () => { + const utxos = Array.from({length: 5}, (_, index) => utxo(ONE_DASH, index)) + expect(maxSelectableAmount(utxos, FEE)).toBe(5n * ONE_DASH - FEE(5)) + }) + + // What Max offers has to be an amount the send can still fund. + it('offers an amount the selection settles on exactly', () => { + const utxos = Array.from({length: 8}, (_, index) => utxo(20_000n, index)) + const max = maxSelectableAmount(utxos, FEE) + + const res = selectCoins(utxos, max, FEE) + + expect(res.change).toBe(0n) + expect(res.inputTotal).toBe(max + res.fee) + }) +}) diff --git a/tests/unit/coreTransaction.test.ts b/tests/unit/coreTransaction.test.ts index 04c4baeb..e7471626 100644 --- a/tests/unit/coreTransaction.test.ts +++ b/tests/unit/coreTransaction.test.ts @@ -39,6 +39,8 @@ describe('CoreTransactionService.buildSignedAssetLock', () => { address: CHANGE_ADDRESS, } + const FEE = 10_000n + it('builds an asset-lock tx with OP_RETURN lock output, change, and credit payload', async () => { const lockAmount = 100_000n const inputTotal = 200_000n @@ -49,6 +51,7 @@ describe('CoreTransactionService.buildSignedAssetLock', () => { creditAddress: CREDIT_ADDRESS, changeAddress: CHANGE_ADDRESS, inputTotal, + feeDuffs: FEE, seed: SEED, network: 'testnet', }) @@ -70,4 +73,79 @@ describe('CoreTransactionService.buildSignedAssetLock', () => { expect(typeof tx.hex()).toBe('string') expect(tx.hex().length).toBeGreaterThan(0) }) + + // The fee is the gap the transaction leaves; any other gap was never quoted. + it('leaves exactly the quoted fee between its inputs and its outputs', async () => { + const lockAmount = 100_000n + const inputTotal = 200_000n + + const tx = await service.buildSignedAssetLock({ + inputs: [input], + amountDuffs: lockAmount, + creditAddress: CREDIT_ADDRESS, + changeAddress: CHANGE_ADDRESS, + inputTotal, + feeDuffs: FEE, + seed: SEED, + network: 'testnet', + }) + + const outputTotal = tx.outputs.reduce((sum, output) => sum + output.satoshis, 0n) + expect(inputTotal - outputTotal).toBe(FEE) + }) + + // generateChange used to invent a change output worth more than the inputs. + it('emits no change output when the send consumes the whole balance', async () => { + const inputTotal = 200_000n + const lockAmount = inputTotal - FEE + + const tx = await service.buildSignedAssetLock({ + inputs: [input], + amountDuffs: lockAmount, + creditAddress: CREDIT_ADDRESS, + changeAddress: CHANGE_ADDRESS, + inputTotal, + feeDuffs: FEE, + seed: SEED, + network: 'testnet', + }) + + expect(tx.outputs).toHaveLength(1) + const outputTotal = tx.outputs.reduce((sum, output) => sum + output.satoshis, 0n) + expect(outputTotal).toBeLessThanOrEqual(inputTotal) + expect(inputTotal - outputTotal).toBe(FEE) + }) + + // Core rejects an output below the dust threshold outright. + it('gives change below the dust threshold to the fee instead of an output', async () => { + const inputTotal = 200_000n + const lockAmount = inputTotal - FEE - 500n + + const tx = await service.buildSignedAssetLock({ + inputs: [input], + amountDuffs: lockAmount, + creditAddress: CREDIT_ADDRESS, + changeAddress: CHANGE_ADDRESS, + inputTotal, + feeDuffs: FEE, + seed: SEED, + network: 'testnet', + }) + + expect(tx.outputs).toHaveLength(1) + expect(inputTotal - tx.outputs[0].satoshis).toBe(FEE + 500n) + }) + + it('refuses inputs that cannot cover the amount and the fee', async () => { + await expect(service.buildSignedAssetLock({ + inputs: [input], + amountDuffs: 200_000n, + creditAddress: CREDIT_ADDRESS, + changeAddress: CHANGE_ADDRESS, + inputTotal: 200_000n, + feeDuffs: FEE, + seed: SEED, + network: 'testnet', + })).rejects.toThrow('do not cover') + }) }) diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index 59a4a326..9b947d42 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -8,7 +8,11 @@ import {Preferences} from '../../src/main/src/preferences' import {FeeOperation, FeeParams} from '../../src/main/platform/types/messages' import {PlatformSourceCandidate} from '../../src/main/src/types/PlatformTransfer' import {FeeQuoteParams} from '../../src/main/platform/types/messages' -import {CORE_TRANSFER_FEE_DUFFS} from '../../src/main/src/constants/chain' +import {Script} from 'dash-core-sdk' +import {AddressDAO} from '../../src/main/src/database/AddressDAO' +import {WalletProviderFactory} from '../../src/main/src/providers/WalletProviderFactory' +import {UTXO} from '../../src/main/src/types/UTXO' +import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' import { DEFAULT_CORE_FEE_MULTIPLIER, DEFAULT_PLATFORM_FEE_MULTIPLIER, @@ -18,6 +22,15 @@ import { const WALLET = 'w1' const IDENTITY = '4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF' const BASE_FEE = 1_000_000n +const CORE_ADDRESS = 'yPx8DNt1oQt3yubB2Sh73vAQRQ1AoyyLCS' +const ONE_DASH = 100_000_000n + +const CORE_FEE = (inputsCount: number): bigint => + coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true) + +function utxo(satoshis: bigint, index: number): UTXO { + return {address: CORE_ADDRESS, txId: `${index}`.padStart(64, '0'), vOut: 0, satoshis, script: new Script()} +} function candidate(platformAddress: string, balanceCredits: bigint, hashByte: number): PlatformSourceCandidate { const addressBytes = new Uint8Array(21) @@ -25,7 +38,7 @@ function candidate(platformAddress: string, balanceCredits: bigint, hashByte: nu return {platformAddress, addressBytes, index: 0, balanceCredits, nonce: 0} } -function service(candidates: PlatformSourceCandidate[] = []): { +function service(candidates: PlatformSourceCandidate[] = [], utxos: UTXO[] = []): { service: FeeService request: ReturnType estimateSpendFee: ReturnType @@ -33,17 +46,24 @@ function service(candidates: PlatformSourceCandidate[] = []): { const request = vi.fn(async (kind: string) => (kind === 'addressInfos' ? {infos: candidates.map(c => ({address: c.platformAddress, balance: c.balanceCredits, nonce: c.nonce}))} : {feeCredits: BASE_FEE, metered: true})) - const estimateSpendFee = vi.fn(async () => ({feeCredits: 7n, feeDuffs: null, maxPerTx: 90n, noteLimit: 6})) + const estimateSpendFee = vi.fn(async () => ({feeCredits: 7n, feeDuffs: null, maxDuffs: null, maxPerTx: 90n, noteLimit: 6})) const walletDAO = { getWalletById: vi.fn().mockResolvedValue({walletId: WALLET, network: 'testnet', platformXpub: 'xpub-test'}), getPlatformAddressCount: vi.fn().mockResolvedValue(candidates.length), } + const addressDAO = { + getAddressesByWalletId: async () => ({receiving: [{address: CORE_ADDRESS}], change: []}), + } + const providers = {forWallet: () => ({getWalletUtxos: async () => utxos})} + const svc = new FeeService( walletDAO as unknown as WalletDAO, + addressDAO as unknown as AddressDAO, {loadCandidates: async () => candidates} as unknown as PlatformAddressService, {request} as unknown as PlatformWorkerService, {estimateSpendFee} as unknown as ShieldedService, + providers as unknown as WalletProviderFactory, Preferences.default(), ) @@ -120,9 +140,9 @@ describe('estimateFee', () => { for (const operation of ['identityToAddress', 'identityToIdentity', 'identityWithdrawal'] as FeeOperation[]) { const {service: svc, request} = service() expect(await svc.estimateFee(WALLET, operation, params({identityId: null}))).toEqual( - {feeCredits: null, feeDuffs: null, maxPerTx: null, noteLimit: null}) + {feeCredits: null, feeDuffs: null, maxDuffs: null, maxPerTx: null, noteLimit: null}) expect(await svc.estimateFee(WALLET, operation, params({amountCredits: 0n}))).toEqual( - {feeCredits: null, feeDuffs: null, maxPerTx: null, noteLimit: null}) + {feeCredits: null, feeDuffs: null, maxDuffs: null, maxPerTx: null, noteLimit: null}) expect(request).not.toHaveBeenCalled() } }) @@ -135,30 +155,67 @@ describe('estimateFee', () => { const {service: svc, estimateSpendFee} = service() const fee = await svc.estimateFee(WALLET, operation, params({noteIndexes: [2, 5]})) expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, operation, 1_000_000n, [2, 5]) - expect(fee).toEqual({feeCredits: 7n, feeDuffs: null, maxPerTx: 90n, noteLimit: 6}) + expect(fee).toEqual({feeCredits: 7n, feeDuffs: null, maxDuffs: null, maxPerTx: 90n, noteLimit: 6}) } }) // The L1 fee used to bypass this method and ride the status poll instead. it('prices a Core send in duffs, from the same method', async () => { - const {service: svc, request} = service() - expect(await svc.estimateFee(WALLET, 'coreSend', params())).toEqual({ + const {service: svc, request} = service([], [utxo(ONE_DASH, 1)]) + expect(await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 1_000n}))).toEqual({ feeCredits: null, - feeDuffs: CORE_TRANSFER_FEE_DUFFS * BigInt(DEFAULT_CORE_FEE_MULTIPLIER), + feeDuffs: CORE_FEE(1), + maxDuffs: ONE_DASH - CORE_FEE(1), maxPerTx: null, noteLimit: null, }) expect(request).not.toHaveBeenCalled() }) + // Quoting one input while the send signed however many the amount needed is + // what let the fee shown and the fee charged disagree. + it('prices a Core send for the inputs the amount actually takes', async () => { + const utxos = [utxo(20_000n, 1), utxo(20_000n, 2), utxo(20_000n, 3)] + const {service: svc} = service([], utxos) + + const fee = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 50_000n})) + + expect(fee.feeDuffs).toBe(CORE_FEE(3)) + }) + + // Max offers this number, so a send of exactly it has to be one the selection + // can still fund at the price it just quoted. + it('offers a maximum the send can fund', async () => { + const utxos = [utxo(20_000n, 1), utxo(20_000n, 2), utxo(20_000n, 3)] + const {service: svc} = service([], utxos) + + const {maxDuffs} = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 0n})) + const atMax = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: maxDuffs})) + + expect(maxDuffs).toBe(60_000n - CORE_FEE(3)) + expect(maxDuffs! + atMax.feeDuffs!).toBe(60_000n) + }) + + // An amount nothing can fund still has to answer, because the quote runs + // while the user is still typing one. + it('falls back to the one-input floor for an amount the selection refuses', async () => { + const {service: svc} = service([], [utxo(20_000n, 1)]) + + const fee = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 900_000n})) + + expect(fee.feeDuffs).toBe(CORE_FEE(1)) + expect(fee.maxDuffs).toBe(20_000n - CORE_FEE(1)) + }) + // An L1 -> L2 transfer is two transactions, and quoting only the lock left the // transition its proof funds unpriced. it('prices both halves of a transfer that locks on L1 and settles on L2', async () => { for (const operation of ['assetLockFunding', 'assetLockShield', 'identityRegister', 'identityTopUpL1'] as FeeOperation[]) { - const {service: svc, request} = service() - expect(await svc.estimateFee(WALLET, operation, params())).toEqual({ + const {service: svc, request} = service([], [utxo(ONE_DASH, 1)]) + expect(await svc.estimateFee(WALLET, operation, params({amountDuffs: 1_000n}))).toEqual({ feeCredits: BASE_FEE * BigInt(DEFAULT_PLATFORM_FEE_MULTIPLIER), - feeDuffs: CORE_TRANSFER_FEE_DUFFS * BigInt(DEFAULT_CORE_FEE_MULTIPLIER), + feeDuffs: CORE_FEE(1), + maxDuffs: ONE_DASH - CORE_FEE(1), maxPerTx: null, noteLimit: null, }) diff --git a/tests/unit/identityRegistrationAmount.test.ts b/tests/unit/identityRegistrationAmount.test.ts index ba39d1c5..0459611c 100644 --- a/tests/unit/identityRegistrationAmount.test.ts +++ b/tests/unit/identityRegistrationAmount.test.ts @@ -7,36 +7,43 @@ import { } from '../../src/renderer/src/utils/identityRegistration' const FEE = 10_000n +const MAX = 100_000_000n - FEE describe('identityRegistrationAmountError', () => { it('accepts the 0.1 Dash minimum when the balance covers the Core fee', () => { - expect(identityRegistrationAmountError('0.1', 10_000_000n, 10_010_000n, FEE)).toBeNull() + expect(identityRegistrationAmountError('0.1', 10_000_000n, 10_000_000n)).toBeNull() }) it('rejects an amount below the identity funding minimum', () => { - expect(identityRegistrationAmountError('0.09999999', 9_999_999n, 100_000_000n, FEE)) + expect(identityRegistrationAmountError('0.09999999', 9_999_999n, MAX)) .toBe('Minimum identity funding is 0.1 Dash.') }) it('rejects an amount that leaves no room for the fees', () => { - expect(identityRegistrationAmountError('1', 100_000_000n, 100_000_000n, FEE)) + expect(identityRegistrationAmountError('1', 100_000_000n, MAX)) .toBe('Max available is 0.9999 Dash after fees.') }) it('rejects malformed and over-precision amounts', () => { - expect(identityRegistrationAmountError('1.2.3', 0n, 100_000_000n, FEE)) + expect(identityRegistrationAmountError('1.2.3', 0n, MAX)) .toBe('Enter a valid Dash amount with up to 8 decimal places.') - expect(identityRegistrationAmountError('0.123456789', 12_345_678n, 100_000_000n, FEE)) + expect(identityRegistrationAmountError('0.123456789', 12_345_678n, MAX)) .toBe('Enter a valid Dash amount with up to 8 decimal places.') }) it('keeps an empty field neutral while the UI disables advancement', () => { - expect(identityRegistrationAmountError('', 0n, 100_000_000n, FEE)).toBeNull() + expect(identityRegistrationAmountError('', 0n, MAX)).toBeNull() + }) + + // The quote that knows the ceiling is still in flight; nothing is over a + // ceiling nobody has drawn yet. + it('holds its verdict while the amount is unpriced', () => { + expect(identityRegistrationAmountError('1', 100_000_000n, null)).toBeNull() }) }) describe('identityRegistrationMaxDuffs', () => { - it('reserves the Core fee and never returns a negative amount', () => { + it('reserves what the transition takes on L2 and never returns a negative amount', () => { expect(identityRegistrationMaxDuffs(100_000_000n, FEE)).toBe(99_990_000n) expect(identityRegistrationMaxDuffs(5_000n, FEE)).toBe(0n) }) diff --git a/tests/unit/transferInputs.test.ts b/tests/unit/transferInputs.test.ts index 7a95f706..4a46d801 100644 --- a/tests/unit/transferInputs.test.ts +++ b/tests/unit/transferInputs.test.ts @@ -8,7 +8,7 @@ import { pickCreditChangeAddress, selectTransferInputs, } from '../../src/main/src/utils/transferInputs' -import {CORE_TRANSFER_FEE_DUFFS} from '../../src/main/src/constants/chain' +import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' const SCRIPT_HEX = '76a9143a2d4145a4f098523b3e8127f1da87cfc55b8e7988ac' // No derivation path in the wallet, so nothing here can be signed. @@ -35,7 +35,7 @@ const wallet = grouped( [address('chg-0', 0, true), address('chg-1', 1, true)], ) -const FEE = CORE_TRANSFER_FEE_DUFFS +const FEE = (inputsCount: number): bigint => coreFeeDuffsFor(1, inputsCount, 1, true) describe('selecting transfer inputs from a wallet-wide utxo set', () => { // Selecting one would fail at signing time, after the spend was built. diff --git a/yarn.lock b/yarn.lock index cb3ea4ba..7e2efd4e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2970,18 +2970,18 @@ csstype@^3.2.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -"dash-core-p2p@https://github.com/pshenmic/dash-core-p2p#542a9d9e250c898e8b7a9ed2781cccaba52739f0": +"dash-core-p2p@https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850": version "0.0.1" - resolved "https://github.com/pshenmic/dash-core-p2p#542a9d9e250c898e8b7a9ed2781cccaba52739f0" + resolved "https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850" dependencies: bloom-filter "^0.1.0" - dash-core-sdk "1.1.3-dev.4" + dash-core-sdk "1.1.3-dev.5" eventemitter3 "^5.0.1" -dash-core-sdk@1.1.3-dev.4: - version "1.1.3-dev.4" - resolved "https://registry.yarnpkg.com/dash-core-sdk/-/dash-core-sdk-1.1.3-dev.4.tgz#1c7ca4f624fe78f158cf9d8d7cbe865a0aa44f25" - integrity sha512-n5stGrjgeYXkPGeNr3V8DWxn6lThHAy3x+PScDne047/iECuvqbYJR3LCCp9O6MvFYAa3onSIRjBmmP5sjkxIA== +dash-core-sdk@1.1.3-dev.5: + version "1.1.3-dev.5" + resolved "https://registry.yarnpkg.com/dash-core-sdk/-/dash-core-sdk-1.1.3-dev.5.tgz#003eea05b8057066196ed938f7745232c3a872c8" + integrity sha512-45YhAmk6Dx4spmv6KIfRJMwRYoSrpyIzzx0LWsuIxyCGLeGOMcpOvvhc/C/mr7+2Qp0EsLITQKGP3BkHZeVGTg== dependencies: "@dashevo/x11-hash-js" "^1.0.2" "@noble/curves" "^2.0.1" From 698e1133ec895e3461fc817eb0d4b990afa046a3 Mon Sep 17 00:00:00 2001 From: owl352 Date: Sat, 29 Aug 2026 12:10:09 +0300 Subject: [PATCH 02/31] fix asset lock size calculation --- src/main/src/constants/chain.ts | 2 ++ src/main/src/services/core/CoreLockService.ts | 3 ++- src/main/src/services/wallet/FeeService.ts | 9 +++++---- src/main/src/utils/coreFeeRate.ts | 4 ++-- tests/unit/assetLockTx.test.ts | 9 +++++++++ tests/unit/estimateFee.test.ts | 7 +++++-- 6 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/src/constants/chain.ts b/src/main/src/constants/chain.ts index f3a52bec..a9ff324b 100644 --- a/src/main/src/constants/chain.ts +++ b/src/main/src/constants/chain.ts @@ -5,6 +5,8 @@ export const CORE_FEE_PER_BYTE = 1 export const DUST_THRESHOLD_DUFFS = 546n +export const ASSET_LOCK_PAYLOAD_BYTES = 37 + // A coinbase input names no parent transaction. export const COINBASE_PREV_TXID = '0'.repeat(64) diff --git a/src/main/src/services/core/CoreLockService.ts b/src/main/src/services/core/CoreLockService.ts index da1298c9..38ededbf 100644 --- a/src/main/src/services/core/CoreLockService.ts +++ b/src/main/src/services/core/CoreLockService.ts @@ -6,6 +6,7 @@ import {Network} from '../../types/Network' import {Transaction} from '../../types/Transaction' import {TxLockStatus} from '../../types/TxLockStatus' import {pickCreditChangeAddress, selectTransferInputs} from '../../utils/transferInputs' +import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {Preferences} from '../../preferences' import {requireWallet} from '../../utils/requireWallet' @@ -57,7 +58,7 @@ export class CoreLockService implements AssetLockFunder { grouped, await provider.getWalletUtxos(), amountDuffs, - inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true), + inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true, ASSET_LOCK_PAYLOAD_BYTES), ) const creditTarget = credit ?? pickCreditChangeAddress(grouped, changeAddress) diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index 734a835b..ff495da8 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -14,6 +14,7 @@ import { SelectionFeeOperation, TransitionFeeOperation, } from '../../../platform/types/messages' +import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' import {requireWallet} from '../../utils/requireWallet' import {maxSelectableAmount, selectCoins} from '../../utils/coinSelection' import {selectPlatformInputsWithFee} from '../../utils/platformTransfer' @@ -69,7 +70,7 @@ export class FeeService { // Paid in Dash on L1, per byte, so the quote runs the selection the send // will run rather than a floor the send is free to exceed. case 'coreSend': - return {feeCredits: null, ...await this.coreQuote(wallet, params), maxPerTx: null, noteLimit: null} + return {feeCredits: null, ...await this.coreQuote(wallet, params, 0), maxPerTx: null, noteLimit: null} // Two transactions, so two fees. The L1 lock is paid in Dash on top of the // amount; the transition its proof funds is paid in credits out of what @@ -80,7 +81,7 @@ export class FeeService { case 'identityTopUpL1': return { feeCredits: await this.protocolFee(wallet, operation, params, 1), - ...await this.coreQuote(wallet, params), + ...await this.coreQuote(wallet, params, ASSET_LOCK_PAYLOAD_BYTES), maxPerTx: null, noteLimit: null, } @@ -172,9 +173,9 @@ export class FeeService { // selection over the same coins the send will. maxDuffs is what those coins // can fund at their own price, which is the only amount a Max can offer // without the send refusing it. - private async coreQuote(wallet: Wallet, params: FeeParams): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { + private async coreQuote(wallet: Wallet, params: FeeParams, payloadBytes: number): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { const feeForInputs = (inputsCount: number): bigint => - coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, 1, true) + coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, 1, true, payloadBytes) const grouped = await this.addressDAO.getAddressesByWalletId(wallet.walletId) const utxos = await this.providers.forWallet(wallet.walletId, wallet.network).getWalletUtxos() diff --git a/src/main/src/utils/coreFeeRate.ts b/src/main/src/utils/coreFeeRate.ts index 23022be6..075b4ec6 100644 --- a/src/main/src/utils/coreFeeRate.ts +++ b/src/main/src/utils/coreFeeRate.ts @@ -17,7 +17,7 @@ export function coreFeePerByte(multiplier: number): number { return rate } -export function coreFeeDuffsFor(multiplier: number, inputsCount: number, outputsCount: number, withChange: boolean): bigint { +export function coreFeeDuffsFor(multiplier: number, inputsCount: number, outputsCount: number, withChange: boolean, payloadBytes = 0): bigint { const feePerByte = coreFeePerByte(multiplier) // dummy script which bigger than 99% of sigs @@ -45,5 +45,5 @@ export function coreFeeDuffsFor(multiplier: number, inputsCount: number, outputs tx.generateChange('111111111111111111111111133izVn', BigInt(tx.bytes().length)*4n) } - return BigInt(tx.bytes().length * feePerByte) + return BigInt((tx.bytes().length + payloadBytes) * feePerByte) } diff --git a/tests/unit/assetLockTx.test.ts b/tests/unit/assetLockTx.test.ts index 16af224e..3abc2292 100644 --- a/tests/unit/assetLockTx.test.ts +++ b/tests/unit/assetLockTx.test.ts @@ -8,6 +8,7 @@ import { CREDITS_PER_DUFF, SHIELD_FUNDING_FEE_RESERVE_CREDITS, } from '../../src/main/src/constants/credits' +import { ASSET_LOCK_PAYLOAD_BYTES } from '../../src/main/src/constants/chain' const keyHash = new Uint8Array(20).fill(9) const creditAddress = sdkUtils.publicKeyHashToAddress(keyHash, 'testnet') const AMOUNT = 100_000n @@ -19,6 +20,14 @@ describe('buildAssetLockOutputs', () => { expect(burnOutput.hex()).toBe('a086010000000000026a00') }) + // The fee charges for these bytes before the payload exists, so the constant + // it charges by has to be the size this builds. + it('builds the payload the fee constant is sized for', () => { + const {extraPayload} = buildAssetLockOutputs(AMOUNT, creditAddress) + const payload = extraPayload.bytes().length + expect(payload + sdkUtils.getCompactVariableSize(payload)).toBe(ASSET_LOCK_PAYLOAD_BYTES) + }) + it('builds a version-1 payload with a single p2pkh credit output', () => { const {extraPayload} = buildAssetLockOutputs(AMOUNT, creditAddress) expect(extraPayload.version).toBe(ASSET_LOCK_PAYLOAD_VERSION) diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index 9b947d42..f905e811 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -12,6 +12,7 @@ import {Script} from 'dash-core-sdk' import {AddressDAO} from '../../src/main/src/database/AddressDAO' import {WalletProviderFactory} from '../../src/main/src/providers/WalletProviderFactory' import {UTXO} from '../../src/main/src/types/UTXO' +import {ASSET_LOCK_PAYLOAD_BYTES} from '../../src/main/src/constants/chain' import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' import { DEFAULT_CORE_FEE_MULTIPLIER, @@ -27,6 +28,8 @@ const ONE_DASH = 100_000_000n const CORE_FEE = (inputsCount: number): bigint => coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true) +const ASSET_LOCK_FEE = (inputsCount: number): bigint => + coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true, ASSET_LOCK_PAYLOAD_BYTES) function utxo(satoshis: bigint, index: number): UTXO { return {address: CORE_ADDRESS, txId: `${index}`.padStart(64, '0'), vOut: 0, satoshis, script: new Script()} @@ -214,8 +217,8 @@ describe('estimateFee', () => { const {service: svc, request} = service([], [utxo(ONE_DASH, 1)]) expect(await svc.estimateFee(WALLET, operation, params({amountDuffs: 1_000n}))).toEqual({ feeCredits: BASE_FEE * BigInt(DEFAULT_PLATFORM_FEE_MULTIPLIER), - feeDuffs: CORE_FEE(1), - maxDuffs: ONE_DASH - CORE_FEE(1), + feeDuffs: ASSET_LOCK_FEE(1), + maxDuffs: ONE_DASH - ASSET_LOCK_FEE(1), maxPerTx: null, noteLimit: null, }) From 6b6bba5f4463475988ad9673cfbb12f1728daccd Mon Sep 17 00:00:00 2001 From: owl352 Date: Sat, 29 Aug 2026 12:33:55 +0300 Subject: [PATCH 03/31] optimize calculations --- src/main/src/utils/coreFeeRate.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/main/src/utils/coreFeeRate.ts b/src/main/src/utils/coreFeeRate.ts index 075b4ec6..ca6c2166 100644 --- a/src/main/src/utils/coreFeeRate.ts +++ b/src/main/src/utils/coreFeeRate.ts @@ -1,5 +1,6 @@ import {Input, Output, Script, Transaction} from 'dash-core-sdk' import {CORE_FEE_PER_BYTE} from '../constants/chain' +import {getCompactVariableSize} from "dash-core-sdk/src/utils"; // Consensus rejects a withdrawal whose coreFeePerByte is not a non-zero // Fibonacci number, so a multiplied rate has to be snapped onto the sequence. @@ -23,27 +24,24 @@ export function coreFeeDuffsFor(multiplier: number, inputsCount: number, outputs // dummy script which bigger than 99% of sigs const dummyScript = new Script(`OP_PUSHDATA1 ${'0'.repeat(288)}`) - const dummyInputs: Input[] = [] - const dummyOutputs: Output[] = [] - for(let i = 0; i < inputsCount; i++) { - dummyInputs.push(new Input('0'.repeat(64), 1, dummyScript, 0)) - } - - for(let i = 0; i < outputsCount; i++) { - dummyOutputs.push(new Output(1n, dummyScript)) - } + const dummyInput = new Input('0'.repeat(64), 1, dummyScript, 0) + const dummyOutput = new Output(1n, dummyScript) const tx = new Transaction( - dummyInputs, - dummyOutputs, + [dummyInput], + [dummyOutput], 0, 0, ) if(withChange) { - tx.generateChange('111111111111111111111111133izVn', BigInt(tx.bytes().length)*4n) + tx.outputs.push(Output.createP2PKH(1n, '111111111111111111111111133izVn')) } - return BigInt((tx.bytes().length + payloadBytes) * feePerByte) + // one already in tx + const inputsSize = dummyInput.bytes().length * (inputsCount - 1) + getCompactVariableSize(inputsCount) - 1 + const outputsSize = dummyOutput.bytes().length * (outputsCount - 1) + getCompactVariableSize(outputsCount) - 1 + + return BigInt((tx.bytes().length + inputsSize + outputsSize + payloadBytes) * feePerByte) } From 4f0585aeea05457b5cbca16cde5f0deb687bcb75 Mon Sep 17 00:00:00 2001 From: owl352 Date: Sat, 29 Aug 2026 12:36:57 +0300 Subject: [PATCH 04/31] fix import --- src/main/src/utils/coreFeeRate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/src/utils/coreFeeRate.ts b/src/main/src/utils/coreFeeRate.ts index ca6c2166..3c7fcaae 100644 --- a/src/main/src/utils/coreFeeRate.ts +++ b/src/main/src/utils/coreFeeRate.ts @@ -1,6 +1,6 @@ import {Input, Output, Script, Transaction} from 'dash-core-sdk' import {CORE_FEE_PER_BYTE} from '../constants/chain' -import {getCompactVariableSize} from "dash-core-sdk/src/utils"; +import {getCompactVariableSize} from "dash-core-sdk/src/utils.js"; // Consensus rejects a withdrawal whose coreFeePerByte is not a non-zero // Fibonacci number, so a multiplied rate has to be snapped onto the sequence. From 8814964b6d348dd71d2776a0b03968cae7d22d9a Mon Sep 17 00:00:00 2001 From: owl352 Date: Sun, 30 Aug 2026 01:56:22 +0300 Subject: [PATCH 05/31] implement coin control for l1 transfers and utxo ipc endpoint --- src/main/platform/types/messages.ts | 5 ++ src/main/src/WalletBackend.ts | 4 +- src/main/src/api/wallet/getUtxos.ts | 15 ++++ src/main/src/api/wallet/sendTransaction.ts | 5 +- src/main/src/api/walletSync/getUtxos.ts | 15 ---- .../src/providers/DashscanWalletProvider.ts | 3 +- src/main/src/providers/P2PWalletProvider.ts | 1 + .../src/services/core/WalletSyncService.ts | 9 +-- src/main/src/services/wallet/FeeService.ts | 14 ++-- src/main/src/services/wallet/WalletService.ts | 16 +++- src/main/src/types/CoinSelection.ts | 16 ++++ src/main/src/types/UTXO.ts | 3 + src/main/src/utils/coinSelection.ts | 38 +++++++++- src/main/src/utils/transferInputs.ts | 33 +++++++-- src/preload/definitions.ts | 10 ++- src/preload/index.d.ts | 20 ++++- src/renderer/src/api/index.ts | 10 ++- src/renderer/src/api/types.ts | 25 +++++++ .../src/components/modal/SendConfirmModal.tsx | 8 +- .../components/pages/transfer/TransferHub.tsx | 10 ++- src/renderer/src/hooks/useOperationFee.ts | 18 +++-- tests/api/walletUtxos.test.ts | 73 +++++++++++++++++++ tests/unit/coinSelection.test.ts | 59 ++++++++++++++- tests/unit/estimateFee.test.ts | 59 ++++++++++++++- tests/unit/transferInputs.test.ts | 52 ++++++++++++- 25 files changed, 449 insertions(+), 72 deletions(-) create mode 100644 src/main/src/api/wallet/getUtxos.ts delete mode 100644 src/main/src/api/walletSync/getUtxos.ts create mode 100644 tests/api/walletUtxos.test.ts diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index 8ac7d8d0..945b4aa7 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -1,4 +1,5 @@ import {NodeStatus} from 'dash-platform-sdk/types.js' +import {CoreSpendSource} from '../../src/types/CoinSelection' import {Network} from '../../src/types/Network' // Wire protocol for the dash-platform utility process. Envelope only — payload @@ -133,6 +134,10 @@ export interface FeeParams { recipient: string | string[] // L1 quotes only: the fee scales with the inputs the amount takes. amountDuffs?: bigint | null + // L1 quotes only: narrows the funding to one Core address, or to coins the + // user picked. Kept apart from sourceAddress, which names a platform address + // and so matches no L1 coin at all. + coreSource?: CoreSpendSource | null // Optional because most operations read none of them, and a caller spelling // out which fields it does not use says nothing about the fee. sourceAddress?: string | null diff --git a/src/main/src/WalletBackend.ts b/src/main/src/WalletBackend.ts index 51f182b9..9d7fd8f3 100644 --- a/src/main/src/WalletBackend.ts +++ b/src/main/src/WalletBackend.ts @@ -91,7 +91,7 @@ import {DeleteContactHandler} from './api/contacts/deleteContact' import {StartWalletSyncHandler} from './api/walletSync/startWalletSync' import {StopWalletSyncHandler} from './api/walletSync/stopWalletSync' import {ResetWalletSyncHandler} from './api/walletSync/resetWalletSync' -import {GetUtxosHandler} from './api/walletSync/getUtxos' +import {GetUtxosHandler} from './api/wallet/getUtxos' import {DISCOVERY_INTERVAL_MS} from './constants/addresses' import {CoreDiscoveryService} from './services/core/CoreDiscoveryService' import {CorePrevOutService} from './services/core/CorePrevOutService' @@ -184,7 +184,7 @@ export class WalletBackend { ipcMain.handle('startWalletSync', new StartWalletSyncHandler(this.walletSyncService).handle) ipcMain.handle('stopWalletSync', new StopWalletSyncHandler(this.walletSyncService).handle) ipcMain.handle('resetWalletSync', new ResetWalletSyncHandler(this.walletSyncService).handle) - ipcMain.handle('getUtxos', new GetUtxosHandler(this.walletSyncService).handle) + ipcMain.handle('getUtxos', new GetUtxosHandler(this.walletService).handle) ipcMain.handle('hasSyncProgress', new HasSyncProgressHandler(this.walletSyncService).handle) ipcMain.handle('broadcastTransaction', new BroadcastTransactionHandler(this.walletSyncService).handle) ipcMain.handle('getExchangeRates', new GetExchangeRatesHandler(this.ratesService).handle) diff --git a/src/main/src/api/wallet/getUtxos.ts b/src/main/src/api/wallet/getUtxos.ts new file mode 100644 index 00000000..7c509f0f --- /dev/null +++ b/src/main/src/api/wallet/getUtxos.ts @@ -0,0 +1,15 @@ +import { IpcMainInvokeEvent } from 'electron/utility' +import { WalletService } from '../../services/wallet/WalletService' +import { SelectableUtxo } from '../../types/CoinSelection' + +export class GetUtxosHandler { + private walletService: WalletService + + constructor(walletService: WalletService) { + this.walletService = walletService + } + + handle = async (_event: IpcMainInvokeEvent, walletId: string): Promise => { + return this.walletService.getUtxos(walletId) + } +} diff --git a/src/main/src/api/wallet/sendTransaction.ts b/src/main/src/api/wallet/sendTransaction.ts index 7ffd9002..748d9622 100644 --- a/src/main/src/api/wallet/sendTransaction.ts +++ b/src/main/src/api/wallet/sendTransaction.ts @@ -1,5 +1,6 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { WalletService } from '../../services/wallet/WalletService' +import { CoreSpendSource } from '../../types/CoinSelection' import { SendResult } from '../../types/SendResult' export class SendTransactionHandler { @@ -15,8 +16,8 @@ export class SendTransactionHandler { toAddress: string, amountDuffs: bigint, password: string, - fromAddress?: string, + source?: CoreSpendSource, ): Promise => { - return this.walletService.sendTransaction(walletId, toAddress, amountDuffs, password, fromAddress) + return this.walletService.sendTransaction(walletId, toAddress, amountDuffs, password, source) } } diff --git a/src/main/src/api/walletSync/getUtxos.ts b/src/main/src/api/walletSync/getUtxos.ts deleted file mode 100644 index 4e49ad81..00000000 --- a/src/main/src/api/walletSync/getUtxos.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {IpcMainInvokeEvent} from 'electron/utility' -import {WalletSyncService} from '../../services/core/WalletSyncService' -import {WalletSyncUtxo} from '../../../p2p/types/walletSync' - -export class GetUtxosHandler { - private walletSyncService: WalletSyncService - - constructor(walletSyncService: WalletSyncService) { - this.walletSyncService = walletSyncService - } - - handle = async (_event: IpcMainInvokeEvent): Promise => { - return this.walletSyncService.getUtxos() - } -} \ No newline at end of file diff --git a/src/main/src/providers/DashscanWalletProvider.ts b/src/main/src/providers/DashscanWalletProvider.ts index 7830b0e9..22a7c198 100644 --- a/src/main/src/providers/DashscanWalletProvider.ts +++ b/src/main/src/providers/DashscanWalletProvider.ts @@ -219,7 +219,8 @@ export class DashscanWalletProvider implements WalletProvider { txId: utxo.prevTxHash as string, vOut: utxo.vOutIndex as number, satoshis: BigInt(utxo.amount ?? '0'), - script: Script.fromHex(utxo.scriptPubKeyHex as string) + script: Script.fromHex(utxo.scriptPubKeyHex as string), + height: utxo.blockHeight ?? 0, })) } diff --git a/src/main/src/providers/P2PWalletProvider.ts b/src/main/src/providers/P2PWalletProvider.ts index f5a08d56..4a27a14a 100644 --- a/src/main/src/providers/P2PWalletProvider.ts +++ b/src/main/src/providers/P2PWalletProvider.ts @@ -64,6 +64,7 @@ export class P2PWalletProvider implements WalletProvider { vOut: u.vout, satoshis: BigInt(u.satoshis), script: this.p2pkhScript(u.address), + height: u.height, })) } diff --git a/src/main/src/services/core/WalletSyncService.ts b/src/main/src/services/core/WalletSyncService.ts index e05aba2f..e897147f 100644 --- a/src/main/src/services/core/WalletSyncService.ts +++ b/src/main/src/services/core/WalletSyncService.ts @@ -15,7 +15,7 @@ import {AddressDAO} from '../../database/AddressDAO' import {TransactionDAO} from '../../database/TransactionDAO' import {P2PCommand, P2PEvent} from '../../../p2p/types/messages' import {BroadcastPolicyOverrides, BroadcastResult} from '../../../p2p/types/broadcast' -import {AppliedBlock, AppliedTx, GapExhausted, WalletSyncStatus, WalletSyncUtxo, WatchAddress} from '../../../p2p/types/walletSync' +import {AppliedBlock, AppliedTx, GapExhausted, WalletSyncStatus, WatchAddress} from '../../../p2p/types/walletSync' import {randomUUID} from 'crypto' import {GENESIS} from '../../../p2p/constants' import {ScanCursorGate} from '../../utils/scanCursorGate' @@ -721,13 +721,6 @@ export class WalletSyncService { this.notifyWalletActivity(walletId) } - // Always sourced from SQL — no main-process cache. Returns [] when no - // wallet is active. - getUtxos = async (): Promise => { - if (!this.activeWalletId) return [] - return this.transactionDAO.getUtxos(this.activeWalletId) - } - resetSync = async (network: 'mainnet' | 'testnet'): Promise => { await this.shutdown() // Queued writes outlive the child: one still retrying here would land after diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index ff495da8..a44fd9ae 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -177,18 +177,22 @@ export class FeeService { const feeForInputs = (inputsCount: number): bigint => coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, 1, true, payloadBytes) + const source = params.coreSource ?? undefined const grouped = await this.addressDAO.getAddressesByWalletId(wallet.walletId) const utxos = await this.providers.forWallet(wallet.walletId, wallet.network).getWalletUtxos() - const selectable = selectableTransferUtxos(grouped, utxos, params.sourceAddress ?? undefined) + const selectable = selectableTransferUtxos(grouped, utxos, source) - const maxDuffs = maxSelectableAmount(selectable, feeForInputs) + const maxDuffs = maxSelectableAmount(selectable, feeForInputs, source) const amountDuffs = params.amountDuffs ?? 0n // A quote is asked for before the amount is affordable, so one the selection - // would refuse answers with the one-input floor rather than failing. + // would refuse answers with a floor rather than failing. A picked set has no + // floor to guess at: its count is the count the send will charge for. + const floorInputs = source?.kind === 'outpoints' ? Math.max(selectable.length, 1) : 1 + const feeDuffs = amountDuffs > 0n && amountDuffs <= maxDuffs - ? selectCoins(selectable, amountDuffs, feeForInputs).fee - : feeForInputs(1) + ? selectCoins(selectable, amountDuffs, feeForInputs, source).fee + : feeForInputs(floorInputs) return {feeDuffs, maxDuffs} } diff --git a/src/main/src/services/wallet/WalletService.ts b/src/main/src/services/wallet/WalletService.ts index 23c30036..e4751aa0 100644 --- a/src/main/src/services/wallet/WalletService.ts +++ b/src/main/src/services/wallet/WalletService.ts @@ -29,7 +29,8 @@ import { import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {identityPath} from '../../utils/identityKeys' import {coreAccountPath, coreAddressDeriver} from "../../utils/addressDiscovery"; -import {selectTransferInputs} from '../../utils/transferInputs' +import {CoreSpendSource, SelectableUtxo} from '../../types/CoinSelection' +import {selectableTransferUtxos, selectTransferInputs} from '../../utils/transferInputs' import {Preferences} from '../../preferences' import {ConnectionStatus} from '../../types/ConnectionStatus' @@ -260,6 +261,15 @@ export class WalletService { return provider.getWalletTransactions() } + async getUtxos(walletId: string): Promise { + const wallet = await requireWallet(this.walletDAO, walletId) + + const provider = this.providers.forWallet(wallet.walletId, wallet.network) + const grouped = await this.addressDAO.getAddressesByWalletId(walletId) + + return selectableTransferUtxos(grouped, await provider.getWalletUtxos()) + } + async getTransactionByHash(hash: string, network: Network): Promise { if (network !== 'mainnet' && network !== 'testnet') { throw new Error('Invalid network ("mainnet", "testnet")') @@ -308,7 +318,7 @@ export class WalletService { toAddress: string, amountDuffs: bigint, password: string, - fromAddress?: string, + source?: CoreSpendSource, ): Promise { if (amountDuffs <= 0n) { throw new Error('Send amount must be greater than zero') @@ -327,7 +337,7 @@ export class WalletService { await provider.getWalletUtxos(), amountDuffs, inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true), - fromAddress, + source, ) const tx = await this.coreTransactionService.buildSignedTransfer({ diff --git a/src/main/src/types/CoinSelection.ts b/src/main/src/types/CoinSelection.ts index cda600b4..33cecfad 100644 --- a/src/main/src/types/CoinSelection.ts +++ b/src/main/src/types/CoinSelection.ts @@ -1,10 +1,26 @@ +// Every coin a send may draw on, and the only shape the renderer is offered: +// listing what can be picked and selecting from it are the same set. export interface SelectableUtxo { txid: string vout: number satoshis: bigint address: string + height: number } +export interface Outpoint { + txid: string + vout: number +} + +// How a send was restricted to part of the wallet. An address narrows the pool +// the automatic selection draws from and still lets it pick; an outpoint list +// is the input set itself, spent whole, which is the only way a send can +// consolidate coins an amount would never have reached for. +export type CoreSpendSource = + | {kind: 'address'; address: string} + | {kind: 'outpoints'; outpoints: Outpoint[]} + export interface CoinSelectionResult { inputs: SelectableUtxo[] inputTotal: bigint diff --git a/src/main/src/types/UTXO.ts b/src/main/src/types/UTXO.ts index 10050c82..d9f037e3 100644 --- a/src/main/src/types/UTXO.ts +++ b/src/main/src/types/UTXO.ts @@ -6,4 +6,7 @@ export interface UTXO { script: Script txId: string vOut: number + // 0 while the output is still in the mempool, matching the block_height the + // local store writes for an unconfirmed transaction. + height: number } diff --git a/src/main/src/utils/coinSelection.ts b/src/main/src/utils/coinSelection.ts index a2c8b888..0b164c61 100644 --- a/src/main/src/utils/coinSelection.ts +++ b/src/main/src/utils/coinSelection.ts @@ -1,17 +1,39 @@ -import {CoinSelectionResult, CoreFeeForInputs, SelectableUtxo} from '../types/CoinSelection' +import {CoinSelectionResult, CoreFeeForInputs, CoreSpendSource, SelectableUtxo} from '../types/CoinSelection' const bySatoshisDesc = (a: SelectableUtxo, b: SelectableUtxo): number => a.satoshis < b.satoshis ? 1 : a.satoshis > b.satoshis ? -1 : 0 +const totalOf = (utxos: SelectableUtxo[]): bigint => + utxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) + export function selectCoins( utxos: SelectableUtxo[], target: bigint, feeForInputs: CoreFeeForInputs, + source?: CoreSpendSource, ): CoinSelectionResult { if (target <= 0n) { throw new Error('Send amount must be greater than zero') } + // A picked set is spent whole rather than walked: stopping early would drop + // coins the user asked to spend, which is the one thing picking them means. + if (source?.kind === 'outpoints') { + if (utxos.length === 0) { + throw new Error('Insufficient funds to cover amount and network fee') + } + + const inputTotal = totalOf(utxos) + const fee = feeForInputs(utxos.length) + const change = inputTotal - target - fee + + if (change < 0n) { + throw new Error('Insufficient funds to cover amount and network fee') + } + + return {inputs: [...utxos], inputTotal, fee, change} + } + const sorted = [...utxos].sort(bySatoshisDesc) const selected: SelectableUtxo[] = [] @@ -33,7 +55,19 @@ export function selectCoins( // Not the balance minus a fee: an input worth less than what it adds to the fee // leaves the set able to send less, so the answer is the best prefix. -export function maxSelectableAmount(utxos: SelectableUtxo[], feeForInputs: CoreFeeForInputs): bigint { +export function maxSelectableAmount( + utxos: SelectableUtxo[], + feeForInputs: CoreFeeForInputs, + source?: CoreSpendSource, +): bigint { + // No prefix to choose from when every coin is spent: the price is the one the + // picked count carries, whether or not a smaller set would have been cheaper. + if (source?.kind === 'outpoints') { + if (utxos.length === 0) return 0n + const spendable = totalOf(utxos) - feeForInputs(utxos.length) + return spendable > 0n ? spendable : 0n + } + const sorted = [...utxos].sort(bySatoshisDesc) let inputTotal = 0n diff --git a/src/main/src/utils/transferInputs.ts b/src/main/src/utils/transferInputs.ts index 816c8c29..b503aa3e 100644 --- a/src/main/src/utils/transferInputs.ts +++ b/src/main/src/utils/transferInputs.ts @@ -1,9 +1,16 @@ import {GroupedAddresses} from '../types/GroupedAddresses' -import {CoreFeeForInputs, SelectableUtxo} from '../types/CoinSelection' +import {CoreFeeForInputs, CoreSpendSource, SelectableUtxo} from '../types/CoinSelection' import {TransferInput, TransferInputSelection} from '../types/CoreTransaction' import {UTXO} from '../types/UTXO' import {selectCoins} from './coinSelection' +const outpointKey = (txid: string, vout: number): string => `${txid}:${vout}` + +const pickedOutpointKeys = (source?: CoreSpendSource): Set | null => + source?.kind === 'outpoints' + ? new Set(source.outpoints.map(outpoint => outpointKey(outpoint.txid, outpoint.vout))) + : null + // Falls back to the last change address, then to a receiving one, so change // never leaves the wallet. export function pickChangeAddress(grouped: GroupedAddresses): string { @@ -34,18 +41,21 @@ export function pickCreditChangeAddress( export function selectableTransferUtxos( grouped: GroupedAddresses, utxos: UTXO[], - fromAddress?: string, + source?: CoreSpendSource, ): SelectableUtxo[] { const owned = new Set([...grouped.receiving, ...grouped.change].map(a => a.address)) + const picked = pickedOutpointKeys(source) return utxos .filter(utxo => owned.has(utxo.address)) - .filter(utxo => fromAddress == null || utxo.address === fromAddress) + .filter(utxo => source?.kind !== 'address' || utxo.address === source.address) + .filter(utxo => picked == null || picked.has(outpointKey(utxo.txId, utxo.vOut))) .map(utxo => ({ txid: utxo.txId, vout: utxo.vOut, satoshis: utxo.satoshis, address: utxo.address, + height: utxo.height, })) } @@ -54,23 +64,30 @@ export function selectTransferInputs( utxos: UTXO[], amountDuffs: bigint, feeForInputs: CoreFeeForInputs, - fromAddress?: string, + source?: CoreSpendSource, ): TransferInputSelection { const pathByAddress = new Map( [...grouped.receiving, ...grouped.change].map(a => [a.address, a.derivationPath]), ) - const selectable = selectableTransferUtxos(grouped, utxos, fromAddress) + const selectable = selectableTransferUtxos(grouped, utxos, source) if (selectable.length === 0) { throw new Error('No spendable funds in this wallet') } - const selection = selectCoins(selectable, amountDuffs, feeForInputs) - const utxoByKey = new Map(utxos.map(u => [`${u.txId}:${u.vOut}`, u])) + // A quote prices whatever survived, but a send that quietly spent fewer coins + // than were picked would break the one promise picking them makes. + const picked = pickedOutpointKeys(source) + if (picked != null && selectable.length !== picked.size) { + throw new Error('Selected UTXO no longer available') + } + + const selection = selectCoins(selectable, amountDuffs, feeForInputs, source) + const utxoByKey = new Map(utxos.map(u => [outpointKey(u.txId, u.vOut), u])) const transferInputs: TransferInput[] = selection.inputs.map(input => { - const owned = utxoByKey.get(`${input.txid}:${input.vout}`) + const owned = utxoByKey.get(outpointKey(input.txid, input.vout)) if (!owned) throw new Error('Selected UTXO no longer available') const derivationPath = pathByAddress.get(input.address) diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 91d4deeb..f2d6e2af 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -2,6 +2,12 @@ // and the union on others let an unchecked value reach the IPC boundary. type Network = 'mainnet' | 'testnet' +// Mirrors src/main/src/types/CoinSelection, which the bundles do not share: an +// address narrows the automatic selection, a picked outpoint list is spent whole. +type CoreSpendSource = + | { kind: 'address'; address: string } + | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } + export const apiDefinitions = (ipcRenderer) => ({ createWallet: (seedphrase: string, network: Network, password: string) => ipcRenderer.invoke('createWallet', seedphrase, network, password), deleteWallet: (walletId: string) => ipcRenderer.invoke('deleteWallet', walletId), @@ -26,7 +32,7 @@ export const apiDefinitions = (ipcRenderer) => ({ addPlatformAddress: (walletId: string) => ipcRenderer.invoke('addPlatformAddress', walletId), setAddressLabel: (walletId: string, address: string, label: string) => ipcRenderer.invoke('setAddressLabel', walletId, address, label), setWalletLabel: (walletId: string, label: string | null) => ipcRenderer.invoke('setWalletLabel', walletId, label), - sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, fromAddress?: string) => ipcRenderer.invoke('sendTransaction', walletId, toAddress, amountDuffs, password, fromAddress), + sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => ipcRenderer.invoke('sendTransaction', walletId, toAddress, amountDuffs, password, source), getTxLockStatus: (walletId: string, txid: string) => ipcRenderer.invoke('getTxLockStatus', walletId, txid), estimateFee: (walletId: string, operation: string, params: unknown) => ipcRenderer.invoke('estimateFee', walletId, operation, params), sendPlatformTransfer: (walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('sendPlatformTransfer', walletId, fromAddress, toAddress, amountCredits, password), @@ -53,7 +59,7 @@ export const apiDefinitions = (ipcRenderer) => ({ startWalletSync: (walletId: string) => ipcRenderer.invoke('startWalletSync', walletId), stopWalletSync: () => ipcRenderer.invoke('stopWalletSync'), resetWalletSync: (network: Network) => ipcRenderer.invoke('resetWalletSync', network), - getUtxos: () => ipcRenderer.invoke('getUtxos'), + getUtxos: (walletId: string) => ipcRenderer.invoke('getUtxos', walletId), hasSyncProgress: (walletId: string) => ipcRenderer.invoke('hasSyncProgress', walletId), broadcastTransaction: (txHex: string) => ipcRenderer.invoke('broadcastTransaction', txHex), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index d41b93eb..cc8e506a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -4,6 +4,22 @@ import { ElectronAPI } from '@electron-toolkit/preload' // share types, so each spells the two networks out for itself. type Network = 'mainnet' | 'testnet' +// Mirrors src/main/src/types/CoinSelection, which the bundles do not share: an +// address narrows the automatic selection, a picked outpoint list is spent whole. +type CoreSpendSource = + | { kind: 'address'; address: string } + | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } + +// Every coin a send can draw on: what getUtxos lists and what an outpoints +// source picks from. +interface SelectableUtxoDTO { + txid: string + vout: number + satoshis: bigint + address: string + height: number +} + // Hand-maintained alongside definitions.ts, and deliberately a second // declaration of src/main/src/types/Transaction: the three bundles do not share // types. Amounts stay bigint — structured clone carries them as themselves. @@ -74,7 +90,7 @@ declare global { getWalletBalance: (walletId: string) => Promise setAddressLabel: (walletId: string, address: string, label: string) => Promise setWalletLabel: (walletId: string, label: string | null) => Promise - sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, fromAddress?: string) => Promise + sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => Promise getTxLockStatus: (walletId: string, txid: string) => Promise estimateFee: (walletId: string, operation: string, params: unknown) => Promise<{ feeCredits: bigint | null; feeDuffs: bigint | null; maxDuffs: bigint | null; maxPerTx: bigint | null; noteLimit: number | null }> sendPlatformTransfer: (walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string) => Promise @@ -100,7 +116,7 @@ declare global { startWalletSync: (walletId: string) => Promise stopWalletSync: () => Promise resetWalletSync: (network: Network) => Promise - getUtxos: () => Promise + getUtxos: (walletId: string) => Promise hasSyncProgress: (walletId: string) => Promise getExchangeRates: () => Promise saveTextFile: (defaultFileName: string, content: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 1b76d152..6a7e3d5c 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,6 +1,6 @@ import { WalletTxDto } from '@renderer/types/WalletTransaction' import { TransferOperation } from '../enums/TransferOperation' -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SelectableUtxo, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' export class API { private static get api() { @@ -75,6 +75,10 @@ export class API { return this.api.getTransactions(walletId) } + static async getUtxos(walletId: string): Promise { + return this.api.getUtxos(walletId) + } + static async getTransactionByHash(hash: string, network: Network): Promise { return this.api.getTransactionByHash(hash, network) as Promise } @@ -159,8 +163,8 @@ export class API { return this.api.deleteContact(id) } - static async sendTransaction(walletId: string, toAddress: string, amountDuffs: bigint, password: string, fromAddress?: string): Promise { - return this.api.sendTransaction(walletId, toAddress, amountDuffs, password, fromAddress) as Promise + static async sendTransaction(walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { + return this.api.sendTransaction(walletId, toAddress, amountDuffs, password, source) as Promise } static async getTxLockStatus(walletId: string, txid: string): Promise { diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index 8d2ea57c..8142bf6c 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -47,6 +47,28 @@ export interface PlatformAddressDto { } // estimateFee — the one fee endpoint. What gets priced for each operation is +export interface Outpoint { + txid: string + vout: number +} + +// An address narrows the automatic coin selection; a picked outpoint list is +// the input set itself, spent whole. +export type CoreSpendSource = + | { kind: 'address'; address: string } + | { kind: 'outpoints'; outpoints: Outpoint[] } + +// getUtxos — every coin a send can draw on, which is also everything an +// outpoints source may pick from. +export interface SelectableUtxo { + txid: string + vout: number + satoshis: bigint + address: string + // 0 while the output is still in the mempool. + height: number +} + // the backend's business; this carries only what the user chose. export interface FeeParams { amountCredits: bigint @@ -55,6 +77,9 @@ export interface FeeParams { recipient: string | string[] // L1 quotes only: the fee scales with the inputs the amount takes. amountDuffs?: bigint | null + // L1 quotes only: narrows the funding to one Core address, or to coins the + // user picked. Kept apart from sourceAddress, which names a platform address. + coreSource?: CoreSpendSource | null // Optional because most operations read none of them. sourceAddress?: string | null identityId?: string | null diff --git a/src/renderer/src/components/modal/SendConfirmModal.tsx b/src/renderer/src/components/modal/SendConfirmModal.tsx index 70c7e291..86608a6e 100644 --- a/src/renderer/src/components/modal/SendConfirmModal.tsx +++ b/src/renderer/src/components/modal/SendConfirmModal.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Button, CrossIcon, Input, Text, SuccessIcon, CheckIcon } from '../dash-ui-kit-enxtended' import { useTheme } from 'dash-ui-kit/react' import { API } from '@renderer/api' -import { Network, SendResult, TxLockStatus } from '@renderer/api/types' +import { CoreSpendSource, Network, SendResult, TxLockStatus } from '@renderer/api/types' import { ConfirmModalPhase } from '@renderer/enums/ConfirmModalPhase' import { SendLockPhase } from '@renderer/enums/SendLockPhase' import { davToDash } from '@renderer/utils/balance' @@ -22,7 +22,7 @@ interface SendConfirmModalProps { toAddress: string amountDuffs: bigint amountFiat?: string - fromAddress?: string + source?: CoreSpendSource onSuccess: () => void } @@ -46,7 +46,7 @@ export default function SendConfirmModal({ toAddress, amountDuffs, amountFiat, - fromAddress, + source, onSuccess, }: SendConfirmModalProps): React.JSX.Element | null { const { theme } = useTheme() @@ -111,7 +111,7 @@ export default function SendConfirmModal({ setPhase(ConfirmModalPhase.Confirm) return } - const res = await API.sendTransaction(walletId, toAddress, amountDuffs, password, fromAddress) + const res = await API.sendTransaction(walletId, toAddress, amountDuffs, password, source) setResult(res) setPhase(ConfirmModalPhase.Done) onSuccess() diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index b2263ad2..48322ffb 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -48,7 +48,7 @@ import { ShieldedSpendPhase } from "@renderer/enums/ShieldedSpendPhase"; import { AssetLockFundingPhase } from "@renderer/enums/AssetLockFundingPhase"; import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; -import { AssetLockFundingState, PlatformAddressDto, ShieldedSpendState } from "@renderer/api/types"; +import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, ShieldedSpendState } from "@renderer/api/types"; import type { SendDraft } from "@renderer/types/SendDraft"; import type { SpecificSourcePreferences } from "@renderer/types/SpecificSource"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; @@ -192,6 +192,9 @@ function WalletTransferHub(): React.JSX.Element { ) const selectedCoreAddress = coreAddresses.find(a => a.address === specificSourcePreferences.addresses[SourceKind.Core]) ?? coreAddresses[0] const coreSpecificAddress = operation === TransferOperation.CoreSend && useSpecificSource ? selectedCoreAddress : undefined + const coreSpendSource: CoreSpendSource | undefined = coreSpecificAddress + ? { kind: 'address', address: coreSpecificAddress.address } + : undefined const spendableNotes = useMemo( () => (shieldedSync.phase === ShieldedSyncPhase.Done ? shieldedSync.notes.filter(n => !n.spent) : []) @@ -242,7 +245,8 @@ function WalletTransferHub(): React.JSX.Element { recipient: trimmedTo, amountCredits, amountDuffs: isCoreOperation ? amountDuffs : null, - sourceAddress: coreSpecificAddress?.address ?? selectedSource?.platformAddress ?? null, + coreSource: coreSpendSource ?? null, + sourceAddress: selectedSource?.platformAddress ?? null, identityId: selectedIdentity?.identifier ?? null, noteIndexes: shieldedSpecificNotes?.map(note => note.index) ?? null, }) @@ -793,7 +797,7 @@ function WalletTransferHub(): React.JSX.Element { toAddress={trimmedTo} amountDuffs={amountDuffs} amountFiat={amountFiat} - fromAddress={coreSpecificAddress?.address} + source={coreSpendSource} onSuccess={() => { resetForm() if (walletId) { diff --git a/src/renderer/src/hooks/useOperationFee.ts b/src/renderer/src/hooks/useOperationFee.ts index 9490dd83..43fbb4a5 100644 --- a/src/renderer/src/hooks/useOperationFee.ts +++ b/src/renderer/src/hooks/useOperationFee.ts @@ -12,20 +12,26 @@ export function useOperationFee( operation: TransferOperation | null, params: OperationFeeParams, ): OperationFee & { loading: boolean; err: string | null } { - const { destinationValid, amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteIndexes } = params + const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, noteIndexes } = params const noteKey = noteIndexes?.join(',') ?? '' + const coreSourceKey = coreSource == null + ? '' + : coreSource.kind === 'address' + ? coreSource.address + : coreSource.outpoints.map(outpoint => `${outpoint.txid}:${outpoint.vout}`).join(',') const pending = useMemo( () => { if (walletId === null || operation === null || !destinationValid) return null - const feeParams = { amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteIndexes } - return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${sourceAddress}:${identityId}:${noteKey}` } + const feeParams = { amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, noteIndexes } + return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${coreSourceKey}:${sourceAddress}:${identityId}:${noteKey}` } }, - // noteIndexes is keyed by noteKey: a fresh array of the same indexes is the - // same quote, and re-running on identity would re-ask on every render. + // noteIndexes and coreSource are keyed by their string forms: a fresh array + // or object holding the same pick is the same quote, and re-running on + // identity would re-ask on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, sourceAddress, identityId, noteKey], + [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, coreSourceKey, sourceAddress, identityId, noteKey], ) const [settled, setSettled] = useState(null) diff --git a/tests/api/walletUtxos.test.ts b/tests/api/walletUtxos.test.ts new file mode 100644 index 00000000..38319781 --- /dev/null +++ b/tests/api/walletUtxos.test.ts @@ -0,0 +1,73 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest' +import {Script} from 'dash-core-sdk' +import {WalletService} from '../../src/main/src/services/wallet/WalletService' +import {CreateWalletHandler} from '../../src/main/src/api/wallet/createWallet' +import {WalletProvider} from '../../src/main/src/providers/WalletProvider' +import {WalletProviderFactory} from '../../src/main/src/providers/WalletProviderFactory' +import {UTXO} from '../../src/main/src/types/UTXO' +import {harness, PASSWORD, VALID_SEEDPHRASE} from './harness' + +const SCRIPT_HEX = '76a9143a2d4145a4f098523b3e8127f1da87cfc55b8e7988ac' +// Derived by no wallet the harness creates, so nothing can sign for it. +const FOREIGN = 'yPx8DNt1oQt3yubB2Sh73vAQRQ1AoyyLCS' + +const utxo = (address: string, satoshis: bigint, txId: string, height: number): UTXO => + ({address, satoshis, txId, vOut: 0, script: Script.fromHex(SCRIPT_HEX), height}) + +const providerStub = (utxos: UTXO[]): WalletProvider => ({ + getWalletUtxos: async () => utxos, + getWalletBalance: async () => 0n, + getBalance: async () => 0n, + ensureReady: async () => undefined, + getConnectionStatus: async () => 'online', + scanAddressUsage: async () => null, + getUsedAddresses: async () => [], + getWalletTransactions: async () => [], + getAddressInfos: async () => [], + getTransactionByHash: async () => { throw new Error('unused') }, + getTxLockStatus: async () => ({instantLocked: false, chainlocked: false, confirmed: false}), + nextUnusedAddress: async () => '', +}) + +describe('listing the coins a send can draw on', () => { + let walletService: WalletService + let providers: WalletProviderFactory + let createWalletHandler: CreateWalletHandler + let walletId: string + let owned: string + + beforeEach(async () => { + const wired = await harness() + walletService = wired.walletService + providers = wired.providers + createWalletHandler = wired.createWalletHandler + walletId = await createWalletHandler.handle(null as never, VALID_SEEDPHRASE, 'testnet', PASSWORD) + owned = (await walletService.getAddressesByWalletId(walletId)).receiving[0].address + }) + + it('answers with the coin, its outpoint and the height it confirmed at', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([utxo(owned, 50_000n, 'aa', 2_300_000)])) + + expect(await walletService.getUtxos(walletId)).toEqual([ + {txid: 'aa', vout: 0, satoshis: 50_000n, address: owned, height: 2_300_000}, + ]) + }) + + // Offering one would hand the picker a coin whose send refuses at signing. + it('leaves out outputs the wallet has no derivation path for', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([ + utxo(FOREIGN, 900_000_000n, 'aa', 2_300_000), + utxo(owned, 50_000n, 'bb', 2_300_001), + ])) + + const utxos = await walletService.getUtxos(walletId) + + expect(utxos.map(u => u.txid)).toEqual(['bb']) + }) + + it('keeps a mempool output at height zero rather than dropping it', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([utxo(owned, 50_000n, 'aa', 0)])) + + expect((await walletService.getUtxos(walletId))[0].height).toBe(0) + }) +}) diff --git a/tests/unit/coinSelection.test.ts b/tests/unit/coinSelection.test.ts index 1601b766..ea0102f1 100644 --- a/tests/unit/coinSelection.test.ts +++ b/tests/unit/coinSelection.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect } from 'vitest' import {maxSelectableAmount, selectCoins} from '../../src/main/src/utils/coinSelection' -import {SelectableUtxo} from '../../src/main/src/types/CoinSelection' +import {CoreSpendSource, SelectableUtxo} from '../../src/main/src/types/CoinSelection' import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' const FEE = (inputsCount: number): bigint => coreFeeDuffsFor(1, inputsCount, 1, true) const ONE_DASH = 100_000_000n function utxo(satoshis: bigint, n = 0): SelectableUtxo { - return { txid: `tx${n}`, vout: n, satoshis, address: `addr${n}` } + return { txid: `tx${n}`, vout: n, satoshis, address: `addr${n}`, height: 1 } } +const picked = (utxos: SelectableUtxo[]): CoreSpendSource => + ({kind: 'outpoints', outpoints: utxos.map(u => ({txid: u.txid, vout: u.vout}))}) + describe('selectCoins', () => { it('selects a single sufficient utxo and returns change', () => { const res = selectCoins([utxo(ONE_DASH)], ONE_DASH / 2n, FEE) @@ -113,3 +116,55 @@ describe('maxSelectableAmount', () => { expect(res.inputTotal).toBe(max + res.fee) }) }) + +describe('spending a picked set of coins', () => { + // The greedy walk would have stopped at the first coin that covered the + // amount; a pick that dropped coins would not be a pick. + it('spends every coin picked, at the price that count carries', () => { + const utxos = Array.from({length: 4}, (_, index) => utxo(ONE_DASH, index)) + const res = selectCoins(utxos, ONE_DASH / 2n, FEE, picked(utxos)) + + expect(res.inputs).toHaveLength(4) + expect(res.inputTotal).toBe(4n * ONE_DASH) + expect(res.fee).toBe(FEE(4)) + expect(res.change).toBe(4n * ONE_DASH - ONE_DASH / 2n - FEE(4)) + }) + + it('refuses a pick that cannot cover the amount and its fee', () => { + const utxos = [utxo(10_000n, 0), utxo(20_000n, 1)] + + expect(() => selectCoins(utxos, 50_000n, FEE, picked(utxos))).toThrow('Insufficient funds') + }) + + it('refuses a pick with nothing left in it', () => { + expect(() => selectCoins([], ONE_DASH, FEE, {kind: 'outpoints', outpoints: []})) + .toThrow('Insufficient funds') + }) + + // Consolidating dust is the reason to pick coins by hand, so the coins the + // prefix walk skips as not worth their bytes still have to be spendable. + it('prices the whole pick, including coins the automatic walk would skip', () => { + const utxos = [utxo(ONE_DASH, 0), ...Array.from({length: 20}, (_, index) => utxo(10n, index + 1))] + const max = maxSelectableAmount(utxos, FEE, picked(utxos)) + + expect(max).toBe(ONE_DASH + 200n - FEE(21)) + expect(max).toBeLessThan(maxSelectableAmount(utxos, FEE)) + }) + + it('answers zero for a pick worth less than its own fee', () => { + const utxos = [utxo(100n, 0)] + + expect(maxSelectableAmount([], FEE, {kind: 'outpoints', outpoints: []})).toBe(0n) + expect(maxSelectableAmount(utxos, FEE, picked(utxos))).toBe(0n) + }) + + it('offers an amount the pick funds with nothing left over', () => { + const utxos = Array.from({length: 6}, (_, index) => utxo(20_000n, index)) + const max = maxSelectableAmount(utxos, FEE, picked(utxos)) + + const res = selectCoins(utxos, max, FEE, picked(utxos)) + + expect(res.change).toBe(0n) + expect(res.inputs).toHaveLength(6) + }) +}) diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index f905e811..bf214907 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -32,9 +32,12 @@ const ASSET_LOCK_FEE = (inputsCount: number): bigint => coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true, ASSET_LOCK_PAYLOAD_BYTES) function utxo(satoshis: bigint, index: number): UTXO { - return {address: CORE_ADDRESS, txId: `${index}`.padStart(64, '0'), vOut: 0, satoshis, script: new Script()} + return {address: CORE_ADDRESS, txId: `${index}`.padStart(64, '0'), vOut: 0, satoshis, script: new Script(), height: 1} } +const outpoint = (index: number): {txid: string; vout: number} => + ({txid: `${index}`.padStart(64, '0'), vout: 0}) + function candidate(platformAddress: string, balanceCredits: bigint, hashByte: number): PlatformSourceCandidate { const addressBytes = new Uint8Array(21) addressBytes[1] = hashByte @@ -210,6 +213,60 @@ describe('estimateFee', () => { expect(fee.maxDuffs).toBe(20_000n - CORE_FEE(1)) }) + // A picked set is spent whole, so the quote cannot price the prefix the + // automatic selection would have stopped at. + it('prices a Core send for every coin the user picked', async () => { + const utxos = [utxo(20_000n, 1), utxo(20_000n, 2), utxo(20_000n, 3)] + const {service: svc} = service([], utxos) + + const fee = await svc.estimateFee(WALLET, 'coreSend', params({ + amountDuffs: 1_000n, + coreSource: {kind: 'outpoints', outpoints: [outpoint(1), outpoint(2)]}, + })) + + expect(fee.feeDuffs).toBe(CORE_FEE(2)) + expect(fee.maxDuffs).toBe(40_000n - CORE_FEE(2)) + }) + + // The same amount over the same wallet, priced for one input, is what the + // automatic selection answers — the pick is what makes the difference. + it('prices a picked set apart from what the automatic selection would take', async () => { + const utxos = [utxo(20_000n, 1), utxo(20_000n, 2), utxo(20_000n, 3)] + const {service: svc} = service([], utxos) + + const auto = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 1_000n})) + + expect(auto.feeDuffs).toBe(CORE_FEE(1)) + expect(auto.maxDuffs).toBe(60_000n - CORE_FEE(3)) + }) + + // The pick decides the input count, so an amount nothing can fund is still + // priced for the coins that would go in rather than a one-input floor. + it('holds the picked count for an amount the pick cannot cover', async () => { + const utxos = [utxo(20_000n, 1), utxo(20_000n, 2)] + const {service: svc} = service([], utxos) + + const fee = await svc.estimateFee(WALLET, 'coreSend', params({ + amountDuffs: 900_000n, + coreSource: {kind: 'outpoints', outpoints: [outpoint(1), outpoint(2)]}, + })) + + expect(fee.feeDuffs).toBe(CORE_FEE(2)) + }) + + // sourceAddress names a platform address on these operations, and reading it + // as an L1 filter matched no coin at all, so Max offered nothing. + it('funds an asset lock from the whole wallet while a platform source is named', async () => { + const {service: svc} = service([], [utxo(ONE_DASH, 1)]) + + const fee = await svc.estimateFee(WALLET, 'identityRegister', params({ + amountDuffs: 1_000n, + sourceAddress: 'tdash1qsourceplatformaddress', + })) + + expect(fee.maxDuffs).toBe(ONE_DASH - ASSET_LOCK_FEE(1)) + }) + // An L1 -> L2 transfer is two transactions, and quoting only the lock left the // transition its proof funds unpriced. it('prices both halves of a transfer that locks on L1 and settles on L2', async () => { diff --git a/tests/unit/transferInputs.test.ts b/tests/unit/transferInputs.test.ts index 4a46d801..969f0a8b 100644 --- a/tests/unit/transferInputs.test.ts +++ b/tests/unit/transferInputs.test.ts @@ -26,7 +26,7 @@ const address = (name: string, index: number, isChange: boolean, isUsed = false) }) const utxo = (owner: string, satoshis: bigint, txId: string): UTXO => - ({address: owner, satoshis, txId, vOut: 0, script: Script.fromHex(SCRIPT_HEX)}) + ({address: owner, satoshis, txId, vOut: 0, script: Script.fromHex(SCRIPT_HEX), height: 1}) const grouped = (receiving: Address[], change: Address[]): GroupedAddresses => ({receiving, change}) @@ -55,18 +55,64 @@ describe('selecting transfer inputs from a wallet-wide utxo set', () => { .toThrow('No spendable funds') }) - it('honours fromAddress against the wallet-wide set', () => { + it('honours an address source against the wallet-wide set', () => { const {transferInputs} = selectTransferInputs( wallet, [utxo('recv-0', 50_000_000n, 'aa'), utxo('recv-1', 50_000_000n, 'bb')], 1_000_000n, FEE, - 'recv-1', + {kind: 'address', address: 'recv-1'}, ) expect(transferInputs.map(i => i.txId)).toEqual(['bb']) }) + // The automatic selection would have stopped at the first coin that covered + // the amount, which is the one thing a picked set must not do. + it('spends every picked coin even when one of them would have covered the amount', () => { + const {transferInputs, inputTotal, feeDuffs} = selectTransferInputs( + wallet, + [utxo('recv-0', 50_000_000n, 'aa'), utxo('recv-1', 50_000_000n, 'bb')], + 1_000_000n, + FEE, + {kind: 'outpoints', outpoints: [{txid: 'aa', vout: 0}, {txid: 'bb', vout: 0}]}, + ) + + expect(transferInputs.map(i => i.txId)).toEqual(['aa', 'bb']) + expect(inputTotal).toBe(100_000_000n) + expect(feeDuffs).toBe(FEE(2)) + }) + + it('leaves a picked coin the wallet cannot sign for out of the spend', () => { + expect(() => selectTransferInputs( + wallet, + [utxo('recv-0', 50_000_000n, 'aa'), utxo(FOREIGN, 50_000_000n, 'bb')], + 1_000_000n, + FEE, + {kind: 'outpoints', outpoints: [{txid: 'aa', vout: 0}, {txid: 'bb', vout: 0}]}, + )).toThrow('Selected UTXO no longer available') + }) + + it('refuses the spend when a picked coin was spent since it was picked', () => { + expect(() => selectTransferInputs( + wallet, + [utxo('recv-0', 50_000_000n, 'aa')], + 1_000_000n, + FEE, + {kind: 'outpoints', outpoints: [{txid: 'aa', vout: 0}, {txid: 'bb', vout: 0}]}, + )).toThrow('Selected UTXO no longer available') + }) + + it('refuses a picked set that cannot cover the amount and its fee', () => { + expect(() => selectTransferInputs( + wallet, + [utxo('recv-0', 10_000n, 'aa'), utxo('recv-1', 900_000_000n, 'bb')], + 1_000_000n, + FEE, + {kind: 'outpoints', outpoints: [{txid: 'aa', vout: 0}]}, + )).toThrow('Insufficient funds') + }) + it('carries the derivation path of the address each input pays', () => { const {transferInputs} = selectTransferInputs(wallet, [utxo('recv-1', 50_000_000n, 'aa')], 1_000_000n, FEE) From 06991d5b136006699e8ac833107ab2b6fa19e3d9 Mon Sep 17 00:00:00 2001 From: owl352 Date: Sun, 30 Aug 2026 02:36:18 +0300 Subject: [PATCH 06/31] implement coin control for l1 asset lock funding --- .../src/api/wallet/startAssetLockFunding.ts | 10 +- src/main/src/services/core/CoreLockService.ts | 3 + .../src/services/platform/AssetLockService.ts | 2 +- .../platform/IdentityRegistrationService.ts | 9 +- .../platform/PlatformTransferService.ts | 5 +- .../src/services/platform/ShieldedService.ts | 5 +- src/main/src/services/wallet/FeeService.ts | 6 +- src/main/src/types/AssetLock.ts | 6 + src/main/src/utils/coinSelection.ts | 9 ++ src/preload/definitions.ts | 2 +- src/preload/index.d.ts | 2 +- src/renderer/src/api/index.ts | 4 +- .../modal/AssetLockFundingModal.tsx | 6 +- .../components/pages/transfer/TransferHub.tsx | 5 +- src/renderer/src/utils/specificSource.ts | 12 +- tests/api/assetLockCoinControl.test.ts | 103 ++++++++++++++++++ tests/unit/estimateFee.test.ts | 29 +++++ tests/unit/specificSource.test.ts | 9 +- 18 files changed, 203 insertions(+), 24 deletions(-) create mode 100644 tests/api/assetLockCoinControl.test.ts diff --git a/src/main/src/api/wallet/startAssetLockFunding.ts b/src/main/src/api/wallet/startAssetLockFunding.ts index eba727c3..f7345817 100644 --- a/src/main/src/api/wallet/startAssetLockFunding.ts +++ b/src/main/src/api/wallet/startAssetLockFunding.ts @@ -4,6 +4,7 @@ import { IdentityRegistrationService } from '../../services/platform/IdentityReg import { PlatformTransferService } from '../../services/platform/PlatformTransferService' import { ShieldedService } from '../../services/platform/ShieldedService' import {AssetLockFundingKind} from '../../types/AssetLock' +import {CoreSpendSource} from '../../types/CoinSelection' export class StartAssetLockFundingHandler { constructor( @@ -19,16 +20,17 @@ export class StartAssetLockFundingHandler { amountDuffs: bigint, password: string, kind?: AssetLockFundingKind, + source?: CoreSpendSource, ): Promise => { switch (kind ?? 'address') { case 'shielded': - return this.shieldedService.startShieldFromL1(walletId, toPlatformAddress, amountDuffs, password) + return this.shieldedService.startShieldFromL1(walletId, toPlatformAddress, amountDuffs, password, source) case 'identity': - return this.identityRegistrationService.startIdentityCreate(walletId, amountDuffs, password) + return this.identityRegistrationService.startIdentityCreate(walletId, amountDuffs, password, source) case 'identityTopUp': - return this.identityRegistrationService.startIdentityTopUp(walletId, toPlatformAddress, amountDuffs, password) + return this.identityRegistrationService.startIdentityTopUp(walletId, toPlatformAddress, amountDuffs, password, source) case 'address': - return this.platformTransferService.startFundingFromL1(walletId, toPlatformAddress, amountDuffs, password) + return this.platformTransferService.startFundingFromL1(walletId, toPlatformAddress, amountDuffs, password, source) } } } diff --git a/src/main/src/services/core/CoreLockService.ts b/src/main/src/services/core/CoreLockService.ts index 38ededbf..5e67ed18 100644 --- a/src/main/src/services/core/CoreLockService.ts +++ b/src/main/src/services/core/CoreLockService.ts @@ -7,6 +7,7 @@ import {Transaction} from '../../types/Transaction' import {TxLockStatus} from '../../types/TxLockStatus' import {pickCreditChangeAddress, selectTransferInputs} from '../../utils/transferInputs' import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' +import {CoreSpendSource} from '../../types/CoinSelection' import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {Preferences} from '../../preferences' import {requireWallet} from '../../utils/requireWallet' @@ -42,6 +43,7 @@ export class CoreLockService implements AssetLockFunder { amountDuffs: bigint, seed: Uint8Array, credit?: {address: string; derivationPath: string}, + source?: CoreSpendSource, ): Promise { if (amountDuffs <= 0n) { throw new Error('Amount must be greater than zero') @@ -59,6 +61,7 @@ export class CoreLockService implements AssetLockFunder { await provider.getWalletUtxos(), amountDuffs, inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true, ASSET_LOCK_PAYLOAD_BYTES), + source, ) const creditTarget = credit ?? pickCreditChangeAddress(grouped, changeAddress) diff --git a/src/main/src/services/platform/AssetLockService.ts b/src/main/src/services/platform/AssetLockService.ts index 631dd458..03a394c1 100644 --- a/src/main/src/services/platform/AssetLockService.ts +++ b/src/main/src/services/platform/AssetLockService.ts @@ -156,7 +156,7 @@ export class AssetLockService { const {walletId, amountDuffs, seed} = params state.phase = 'broadcastingL1' - const built = await this.funder.buildAssetLock(walletId, amountDuffs, seed, params.credit) + const built = await this.funder.buildAssetLock(walletId, amountDuffs, seed, params.credit, params.source) state.txid = built.txid await this.assetLockDAO.insertFunding({ diff --git a/src/main/src/services/platform/IdentityRegistrationService.ts b/src/main/src/services/platform/IdentityRegistrationService.ts index d4121fc7..bb34a9d0 100644 --- a/src/main/src/services/platform/IdentityRegistrationService.ts +++ b/src/main/src/services/platform/IdentityRegistrationService.ts @@ -3,6 +3,7 @@ import {KeyPairController} from 'dash-platform-sdk/src/keyPair/index.js' import {WalletDAO} from '../../database/WalletDAO' import {IdentityDAO} from '../../database/IdentityDAO' import {AssetLockFundingState} from '../../types/AssetLockFunding' +import {CoreSpendSource} from '../../types/CoinSelection' import {Network} from '../../types/Network' import {AssetLockService} from './AssetLockService' import {PlatformWorkerService} from './PlatformWorkerService' @@ -71,7 +72,7 @@ export class IdentityRegistrationService { // on Platform — skips indices taken by the same seed used elsewhere. // amountDuffs is what the identity ends up with, so the lock also carries the // fee the IdentityCreateTransition takes out of it. - async startIdentityCreate(walletId: string, amountDuffs: bigint, password: string): Promise { + async startIdentityCreate(walletId: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { const unlocked = await unlockWallet(this.walletDAO, walletId, password) try { const {identityIndex, credit} = await this.prepareRegistration(walletId, unlocked) @@ -80,7 +81,7 @@ export class IdentityRegistrationService { const state = await this.assetLock.begin(walletId, 'identity', '', lockDuffs) return this.run(state, unlocked, async () => { const acquired = await this.assetLock.acquire(state, { - walletId, kind: 'identity', destination: '', amountDuffs: lockDuffs, seed: unlocked.seed, credit, identityIndex, + walletId, kind: 'identity', destination: '', amountDuffs: lockDuffs, seed: unlocked.seed, credit, source, identityIndex, }) await this.settleCreate(walletId, unlocked, state, acquired, identityIndex) }) @@ -90,7 +91,7 @@ export class IdentityRegistrationService { } } - async startIdentityTopUp(walletId: string, identityId: string, amountDuffs: bigint, password: string): Promise { + async startIdentityTopUp(walletId: string, identityId: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { if (identityId.trim().length === 0) { throw new Error('Identity identifier is required') } @@ -103,7 +104,7 @@ export class IdentityRegistrationService { return this.run(state, unlocked, async () => { const acquired = await this.assetLock.acquire(state, { walletId, kind: 'identityTopUp', destination: identityId, amountDuffs: lockDuffs, seed: unlocked.seed, - credit, identityIndex: topUpIndex, + credit, source, identityIndex: topUpIndex, }) await this.settleTopUp(unlocked, state, acquired) }) diff --git a/src/main/src/services/platform/PlatformTransferService.ts b/src/main/src/services/platform/PlatformTransferService.ts index 9004ec62..637343f2 100644 --- a/src/main/src/services/platform/PlatformTransferService.ts +++ b/src/main/src/services/platform/PlatformTransferService.ts @@ -5,6 +5,7 @@ import {PlatformWorkerService} from './PlatformWorkerService' import {ShieldedService} from './ShieldedService' import {IdentityDAO} from '../../database/IdentityDAO' import {AssetLockFundingState} from '../../types/AssetLockFunding' +import {CoreSpendSource} from '../../types/CoinSelection' import {Network} from '../../types/Network' import {Wallet} from '../../types/Wallet' import {Identity} from '../../types/Identity' @@ -382,7 +383,7 @@ export class PlatformTransferService { // Locks L1 coins and credits them to one of this wallet's platform addresses. // amountDuffs is what arrives, so the lock also carries the funding // transition's fee. - async startFundingFromL1(walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string): Promise { + async startFundingFromL1(walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { const unlocked = await this.unlock(walletId, password) const {wallet, seed} = unlocked @@ -396,7 +397,7 @@ export class PlatformTransferService { const state = await this.assetLock.begin(walletId, 'address', toPlatformAddress, lockDuffs) return this.runFunding(state, unlocked, async () => { const acquired = await this.assetLock.acquire(state, { - walletId, kind: 'address', destination: toPlatformAddress, amountDuffs: lockDuffs, seed, + walletId, kind: 'address', destination: toPlatformAddress, amountDuffs: lockDuffs, seed, source, }) await this.settleFunding(seed, wallet.network, state, acquired) }) diff --git a/src/main/src/services/platform/ShieldedService.ts b/src/main/src/services/platform/ShieldedService.ts index 33938f3d..3900f3cc 100644 --- a/src/main/src/services/platform/ShieldedService.ts +++ b/src/main/src/services/platform/ShieldedService.ts @@ -1,4 +1,5 @@ import { OrchardAddressWASM } from 'pshenmic-dpp' +import { CoreSpendSource } from '../../types/CoinSelection' import { Network } from '../../types/Network' import { WalletDAO } from '../../database/WalletDAO' import { IdentityDAO } from '../../database/IdentityDAO' @@ -550,7 +551,7 @@ export class ShieldedService { // Locks L1 coins and shields the credits straight into the pool, so they // never sit on a transparent platform address. - async startShieldFromL1(walletId: string, recipient: string, amountDuffs: bigint, password: string): Promise { + async startShieldFromL1(walletId: string, recipient: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { const destination = recipient.trim() if (destination.length === 0) { throw new Error('Shielded recipient address is required') @@ -570,7 +571,7 @@ export class ShieldedService { const state = await this.assetLock.begin(walletId, 'shielded', destination, lockDuffs) return this.runFunding(state, unlocked, async () => { const acquired = await this.assetLock.acquire(state, { - walletId, kind: 'shielded', destination, amountDuffs: lockDuffs, seed, + walletId, kind: 'shielded', destination, amountDuffs: lockDuffs, seed, source, }) await this.settleShield(wallet, seed, state, acquired) }) diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index a44fd9ae..f1d11dd9 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -16,7 +16,7 @@ import { } from '../../../platform/types/messages' import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' import {requireWallet} from '../../utils/requireWallet' -import {maxSelectableAmount, selectCoins} from '../../utils/coinSelection' +import {maxSelectableAmount, requireAutomaticSelection, selectCoins} from '../../utils/coinSelection' import {selectPlatformInputsWithFee} from '../../utils/platformTransfer' import {coreFeeDuffsFor, coreFeePerByte} from '../../utils/coreFeeRate' import {selectableTransferUtxos} from '../../utils/transferInputs' @@ -91,6 +91,7 @@ export class FeeService { case 'unshield': case 'shieldedWithdrawal': case 'identityCreateFromShielded': + requireAutomaticSelection(params.coreSource) return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.noteIndexes ?? null) // Funded by platform addresses: the fee scales with the inputs, so the @@ -98,6 +99,7 @@ export class FeeService { case 'addressWithdrawal': case 'identityCreate': case 'identityTopUp': + requireAutomaticSelection(params.coreSource) return this.credits(await this.selectionFee(wallet, operation, params)) // Spends an identity's balance, so there is no price until one is picked. @@ -105,6 +107,7 @@ export class FeeService { case 'identityToAddress': case 'identityToIdentity': case 'identityWithdrawal': + requireAutomaticSelection(params.coreSource) return this.credits(params.identityId == null || params.amountCredits <= 0n ? null : await this.protocolFee(wallet, operation, params, 1)) @@ -112,6 +115,7 @@ export class FeeService { // One input by construction: neither send ever splits its source. case 'addressFundsTransfer': case 'shield': + requireAutomaticSelection(params.coreSource) return this.credits(await this.protocolFee(wallet, operation, params, 1)) } } diff --git a/src/main/src/types/AssetLock.ts b/src/main/src/types/AssetLock.ts index 0eebdc7a..5b57ff30 100644 --- a/src/main/src/types/AssetLock.ts +++ b/src/main/src/types/AssetLock.ts @@ -1,5 +1,6 @@ import {Transaction as SDKTransaction} from 'dash-core-sdk' import {AssetLockProofParams} from '../../platform/types/messages' +import {CoreSpendSource} from './CoinSelection' import {AssetLockFundingStatus} from '../enums/AssetLockFundingStatus' import {Network} from './Network' import {Transaction} from './Transaction' @@ -43,6 +44,10 @@ export interface AssetLockFunder { amountDuffs: bigint, seed: Uint8Array, credit?: {address: string; derivationPath: string}, + // Which L1 coins fund the lock. The link it writes between them and the L2 + // destination is permanent, so leaving the choice to the caller matters + // more here than on a plain send. + source?: CoreSpendSource, ): Promise broadcastAssetLock(txHex: string): Promise waitForInstantLock(txid: string, timeoutMs: number): Promise @@ -67,5 +72,6 @@ export interface AcquireParams { amountDuffs: bigint seed: Uint8Array credit?: {address: string; derivationPath: string} + source?: CoreSpendSource identityIndex?: number | null } diff --git a/src/main/src/utils/coinSelection.ts b/src/main/src/utils/coinSelection.ts index 0b164c61..f1b97740 100644 --- a/src/main/src/utils/coinSelection.ts +++ b/src/main/src/utils/coinSelection.ts @@ -6,6 +6,15 @@ const bySatoshisDesc = (a: SelectableUtxo, b: SelectableUtxo): number => const totalOf = (utxos: SelectableUtxo[]): bigint => utxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) +// A pick names L1 coins, so it says nothing about an operation funded by +// platform credits, an identity balance or the pool. Refused rather than +// ignored: silently dropping it is what lets a quote and its send disagree. +export function requireAutomaticSelection(source?: CoreSpendSource | null): void { + if (source != null) { + throw new Error('Coin control applies to L1-funded operations only') + } +} + export function selectCoins( utxos: SelectableUtxo[], target: bigint, diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index f2d6e2af..787f254b 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -42,7 +42,7 @@ export const apiDefinitions = (ipcRenderer) => ({ transferIdentityCredits: (walletId: string, fromIdentityId: string, toIdentityId: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('transferIdentityCredits', walletId, fromIdentityId, toIdentityId, amountCredits, password), withdrawIdentityCredits: (walletId: string, identityId: string, toCoreAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('withdrawIdentityCredits', walletId, identityId, toCoreAddress, amountCredits, password), createIdentityFromAddresses: (walletId: string, fromAddress: string | null, amountCredits: bigint, password: string) => ipcRenderer.invoke('createIdentityFromAddresses', walletId, fromAddress, amountCredits, password), - startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string) => ipcRenderer.invoke('startAssetLockFunding', walletId, toPlatformAddress, amountDuffs, password, kind), + startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string, source?: CoreSpendSource) => ipcRenderer.invoke('startAssetLockFunding', walletId, toPlatformAddress, amountDuffs, password, kind, source), getAssetLockFundingState: (walletId: string) => ipcRenderer.invoke('getAssetLockFundingState', walletId), resumeAssetLockFunding: (walletId: string, password: string) => ipcRenderer.invoke('resumeAssetLockFunding', walletId, password), dismissAssetLockFunding: (walletId: string) => ipcRenderer.invoke('dismissAssetLockFunding', walletId), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index cc8e506a..aa2df140 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -100,7 +100,7 @@ declare global { transferIdentityCredits: (walletId: string, fromIdentityId: string, toIdentityId: string, amountCredits: bigint, password: string) => Promise withdrawIdentityCredits: (walletId: string, identityId: string, toCoreAddress: string, amountCredits: bigint, password: string) => Promise createIdentityFromAddresses: (walletId: string, fromAddress: string | null, amountCredits: bigint, password: string) => Promise - startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string) => Promise + startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string, source?: CoreSpendSource) => Promise getAssetLockFundingState: (walletId: string) => Promise resumeAssetLockFunding: (walletId: string, password: string) => Promise dismissAssetLockFunding: (walletId: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 6a7e3d5c..1ae8a840 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -219,8 +219,8 @@ export class API { return this.api.createIdentityFromAddresses(walletId, fromAddress, amountCredits, password) as Promise } - static async startAssetLockFunding(walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind: AssetLockFundingKind = AssetLockFundingKind.Address): Promise { - return this.api.startAssetLockFunding(walletId, toPlatformAddress, amountDuffs, password, kind) as Promise + static async startAssetLockFunding(walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind: AssetLockFundingKind = AssetLockFundingKind.Address, source?: CoreSpendSource): Promise { + return this.api.startAssetLockFunding(walletId, toPlatformAddress, amountDuffs, password, kind, source) as Promise } static async getAssetLockFundingState(walletId: string): Promise { diff --git a/src/renderer/src/components/modal/AssetLockFundingModal.tsx b/src/renderer/src/components/modal/AssetLockFundingModal.tsx index 54d8602e..f40b2cb3 100644 --- a/src/renderer/src/components/modal/AssetLockFundingModal.tsx +++ b/src/renderer/src/components/modal/AssetLockFundingModal.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Button, CrossIcon, Input, Text, SuccessIcon, CheckIcon } from '../dash-ui-kit-enxtended' import { useTheme } from 'dash-ui-kit/react' import { API } from '@renderer/api' -import { AssetLockFundingKind, AssetLockFundingState } from '@renderer/api/types' +import { AssetLockFundingKind, AssetLockFundingState, CoreSpendSource } from '@renderer/api/types' import { AssetLockFundingPhase } from '@renderer/enums/AssetLockFundingPhase' import { LockKind } from '@renderer/enums/LockKind' import Spinner from '@renderer/components/ui/Spinner' @@ -23,6 +23,7 @@ interface AssetLockFundingModalProps { amountDuffs: string resume: boolean kind: AssetLockFundingKind + source?: CoreSpendSource onSuccess: () => void } @@ -115,6 +116,7 @@ export default function AssetLockFundingModal({ amountDuffs, resume, kind, + source, onSuccess, }: AssetLockFundingModalProps): React.JSX.Element | null { const { theme } = useTheme() @@ -204,7 +206,7 @@ export default function AssetLockFundingModal({ } const initial = resume ? await API.resumeAssetLockFunding(walletId, password) - : await API.startAssetLockFunding(walletId, toPlatformAddress, BigInt(amountDuffs), password, kind) + : await API.startAssetLockFunding(walletId, toPlatformAddress, BigInt(amountDuffs), password, kind, source) setState(initial) setStarted(true) setBusy(false) diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 48322ffb..f854ce20 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -191,7 +191,7 @@ function WalletTransferHub(): React.JSX.Element { [receiving, change], ) const selectedCoreAddress = coreAddresses.find(a => a.address === specificSourcePreferences.addresses[SourceKind.Core]) ?? coreAddresses[0] - const coreSpecificAddress = operation === TransferOperation.CoreSend && useSpecificSource ? selectedCoreAddress : undefined + const coreSpecificAddress = specificSourceKind === SourceKind.Core && useSpecificSource ? selectedCoreAddress : undefined const coreSpendSource: CoreSpendSource | undefined = coreSpecificAddress ? { kind: 'address', address: coreSpecificAddress.address } : undefined @@ -397,7 +397,7 @@ function WalletTransferHub(): React.JSX.Element { updateSpecificSourceEnabled(current, enabled))} label={Send from a specific address} /> - {useSpecificSource && operation === TransferOperation.CoreSend && ( + {useSpecificSource && specificSourceKind === SourceKind.Core && ( { resetForm() if (walletId) { diff --git a/src/renderer/src/utils/specificSource.ts b/src/renderer/src/utils/specificSource.ts index 25f04c72..0c18da66 100644 --- a/src/renderer/src/utils/specificSource.ts +++ b/src/renderer/src/utils/specificSource.ts @@ -16,7 +16,17 @@ export function initialSpecificSourcePreferences(): SpecificSourcePreferences { } export function specificSourceKindForOperation(operation: TransferOperation | null): SpecificSourceKind | null { - if (operation === TransferOperation.CoreSend) return SourceKind.Core + // Every operation funded by L1 coins, not just the plain send: an asset lock + // binds the coins it spends to its L2 destination for good. + if ( + operation === TransferOperation.CoreSend + || operation === TransferOperation.AssetLockFunding + || operation === TransferOperation.AssetLockShield + || operation === TransferOperation.IdentityRegister + || operation === TransferOperation.IdentityTopUpL1 + ) { + return SourceKind.Core + } if ( operation === TransferOperation.ShieldedTransfer || operation === TransferOperation.Unshield diff --git a/tests/api/assetLockCoinControl.test.ts b/tests/api/assetLockCoinControl.test.ts new file mode 100644 index 00000000..de26a7aa --- /dev/null +++ b/tests/api/assetLockCoinControl.test.ts @@ -0,0 +1,103 @@ +import {describe, it, expect, beforeEach, vi} from 'vitest' +import {Script, utils as sdkUtils} from 'dash-core-sdk' +import {CoreLockService} from '../../src/main/src/services/core/CoreLockService' +import {WalletService} from '../../src/main/src/services/wallet/WalletService' +import {CreateWalletHandler} from '../../src/main/src/api/wallet/createWallet' +import {WalletProvider} from '../../src/main/src/providers/WalletProvider' +import {WalletProviderFactory} from '../../src/main/src/providers/WalletProviderFactory' +import {CoreSpendSource} from '../../src/main/src/types/CoinSelection' +import {UTXO} from '../../src/main/src/types/UTXO' +import {unlockWallet} from '../../src/main/src/utils/walletSeed' +import {harness, PASSWORD, VALID_SEEDPHRASE} from './harness' + +// Signing checks the scriptPubKey against the address's key, so a stand-in +// hex would fail before the selection could be observed. +const p2pkhScript = (address: string): Script => { + const script = new Script() + script.pushOpCode('OP_DUP') + script.pushOpCode('OP_HASH160') + script.pushOpCode('OP_PUSHBYTES_20', sdkUtils.addressToPublicKeyHash(address)) + script.pushOpCode('OP_EQUALVERIFY') + script.pushOpCode('OP_CHECKSIG') + return script +} + +const utxo = (address: string, satoshis: bigint, txId: string): UTXO => + ({address, satoshis, txId, vOut: 0, script: p2pkhScript(address), height: 2_300_000}) + +const providerStub = (utxos: UTXO[]): WalletProvider => ({ + getWalletUtxos: async () => utxos, + getWalletBalance: async () => 0n, + getBalance: async () => 0n, + ensureReady: async () => undefined, + getConnectionStatus: async () => 'online', + scanAddressUsage: async () => null, + getUsedAddresses: async () => [], + getWalletTransactions: async () => [], + getAddressInfos: async () => [], + getTransactionByHash: async () => { throw new Error('unused') }, + getTxLockStatus: async () => ({instantLocked: false, chainlocked: false, confirmed: false}), + nextUnusedAddress: async () => '', +}) + +const txid = (byte: string): string => byte.repeat(32) + +const outpointsOf = (...txIds: string[]): CoreSpendSource => + ({kind: 'outpoints', outpoints: txIds.map(t => ({txid: t, vout: 0}))}) + +describe('funding an asset lock from picked coins', () => { + let coreLockService: CoreLockService + let walletService: WalletService + let providers: WalletProviderFactory + let createWalletHandler: CreateWalletHandler + let walletId: string + let seed: Uint8Array + let owned: string[] + + beforeEach(async () => { + const wired = await harness() + coreLockService = wired.coreLockService + walletService = wired.walletService + providers = wired.providers + createWalletHandler = wired.createWalletHandler + walletId = await createWalletHandler.handle(null as never, VALID_SEEDPHRASE, 'testnet', PASSWORD) + seed = (await unlockWallet(wired.walletDAO, walletId, PASSWORD)).seed + const grouped = await walletService.getAddressesByWalletId(walletId) + owned = grouped.receiving.slice(0, 3).map(a => a.address) + }) + + // The lock binds the coins it spends to its L2 destination for good, so a + // pick that the funding then ignored would leak the link it was made to avoid. + it('spends exactly the coins picked, not the ones the amount would have taken', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([ + utxo(owned[0], 100_000_000n, txid('aa')), + utxo(owned[1], 20_000_000n, txid('bb')), + utxo(owned[2], 20_000_000n, txid('cc')), + ])) + + const built = await coreLockService.buildAssetLock(walletId, 1_000_000n, seed, undefined, outpointsOf(txid('bb'), txid('cc'))) + + expect(built.tx.inputs.map(i => i.txId)).toEqual([txid('bb'), txid('cc')]) + }) + + it('still selects automatically when nothing was picked', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([ + utxo(owned[0], 100_000_000n, txid('aa')), + utxo(owned[1], 20_000_000n, txid('bb')), + ])) + + const built = await coreLockService.buildAssetLock(walletId, 1_000_000n, seed) + + expect(built.tx.inputs.map(i => i.txId)).toEqual([txid('aa')]) + }) + + it('refuses a pick that cannot cover the lock and its fee', async () => { + vi.spyOn(providers, 'forWallet').mockReturnValue(providerStub([ + utxo(owned[0], 100_000_000n, txid('aa')), + utxo(owned[1], 10_000n, txid('bb')), + ])) + + await expect(coreLockService.buildAssetLock(walletId, 1_000_000n, seed, undefined, outpointsOf(txid('bb')))) + .rejects.toThrow('Insufficient funds') + }) +}) diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index bf214907..bf091854 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -267,6 +267,35 @@ describe('estimateFee', () => { expect(fee.maxDuffs).toBe(ONE_DASH - ASSET_LOCK_FEE(1)) }) + // An asset lock is funded by L1 coins like any other send, and the link it + // writes between them and its L2 destination is the reason to choose them. + it('prices an asset lock for the coins the user picked', async () => { + for (const operation of ['assetLockFunding', 'assetLockShield', 'identityRegister', 'identityTopUpL1'] as FeeOperation[]) { + const utxos = [utxo(20_000_000n, 1), utxo(20_000_000n, 2), utxo(20_000_000n, 3)] + const {service: svc} = service([], utxos) + + const fee = await svc.estimateFee(WALLET, operation, params({ + amountDuffs: 1_000n, + coreSource: {kind: 'outpoints', outpoints: [outpoint(1), outpoint(2)]}, + })) + + expect(fee.feeDuffs).toBe(ASSET_LOCK_FEE(2)) + expect(fee.maxDuffs).toBe(40_000_000n - ASSET_LOCK_FEE(2)) + } + }) + + // A pick names L1 coins, so an operation funded by platform credits, an + // identity balance or the pool has nothing to apply it to. + it('refuses to price an L2-funded operation from a picked set', async () => { + for (const operation of ['identityCreate', 'identityWithdrawal', 'shield', 'shieldedTransfer'] as FeeOperation[]) { + const {service: svc} = service([candidate('tdash1qsource', 10_000_000n, 1)], [utxo(ONE_DASH, 1)]) + + await expect(svc.estimateFee(WALLET, operation, params({ + coreSource: {kind: 'outpoints', outpoints: [outpoint(1)]}, + }))).rejects.toThrow('L1-funded operations only') + } + }) + // An L1 -> L2 transfer is two transactions, and quoting only the lock left the // transition its proof funds unpriced. it('prices both halves of a transfer that locks on L1 and settles on L2', async () => { diff --git a/tests/unit/specificSource.test.ts b/tests/unit/specificSource.test.ts index 0a4c177e..d5d6c3ec 100644 --- a/tests/unit/specificSource.test.ts +++ b/tests/unit/specificSource.test.ts @@ -40,7 +40,14 @@ describe('specific source preferences', () => { [TransferOperation.Unshield, SourceKind.Shielded], [TransferOperation.ShieldedWithdrawal, SourceKind.Shielded], [TransferOperation.IdentityCreateFromShielded, null], - [TransferOperation.AssetLockFunding, null], + // Funded by L1 coins like a plain send, so the same picker applies. + [TransferOperation.AssetLockFunding, SourceKind.Core], + [TransferOperation.AssetLockShield, SourceKind.Core], + [TransferOperation.IdentityRegister, SourceKind.Core], + [TransferOperation.IdentityTopUpL1, SourceKind.Core], + // Funded by platform credits, so there is no L1 coin to pick. + [TransferOperation.IdentityCreate, null], + [TransferOperation.AddressWithdrawal, null], ])('maps %s to its applicable preference', (operation, expected) => { expect(specificSourceKindForOperation(operation)).toBe(expected) }) From 73e24dfcb217787ffa78bbd192a2135aa08585a9 Mon Sep 17 00:00:00 2001 From: owl352 Date: Mon, 31 Aug 2026 00:27:12 +0300 Subject: [PATCH 07/31] implement shielded coin control --- .../0019_shielded_note_nullifier.ts | 19 ++++ src/main/platform/PlatformService.ts | 2 + src/main/platform/constants.ts | 1 + .../shielded/reads/checkNullifiers.ts | 14 +++ .../operations/shielded/spend/spend.ts | 30 ++--- src/main/platform/operations/shielded/sync.ts | 1 + src/main/platform/types/messages.ts | 18 ++- src/main/src/WalletBackend.ts | 2 + .../api/shielded/refreshShieldedSpentNotes.ts | 15 +++ .../src/api/shielded/startShieldedTransfer.ts | 5 +- .../src/api/shielded/startShieldedUnshield.ts | 5 +- .../api/shielded/startShieldedWithdrawal.ts | 5 +- src/main/src/database/ShieldedNoteDAO.ts | 6 +- .../src/services/platform/ShieldedService.ts | 68 ++++++++---- src/main/src/services/wallet/FeeService.ts | 2 +- src/main/src/types/ShieldedNote.ts | 2 + src/main/src/types/ShieldedNoteSelection.ts | 16 ++- src/main/src/utils/index.ts | 2 + src/main/src/utils/shieldedNoteSelection.ts | 51 ++++++++- src/preload/definitions.ts | 13 ++- src/preload/index.d.ts | 13 ++- src/renderer/src/api/index.ts | 18 +-- src/renderer/src/api/types.ts | 10 +- .../pages/identities/Registration.tsx | 2 +- .../components/pages/transfer/TransferHub.tsx | 17 ++- src/renderer/src/hooks/useOperationFee.ts | 8 +- tests/unit/estimateFee.test.ts | 8 +- tests/unit/shieldedNoteSelection.test.ts | 103 +++++++++++++++++- tests/unit/shieldedSpentRefresh.test.ts | 81 ++++++++++++++ tests/unit/transitionFee.test.ts | 2 +- 30 files changed, 459 insertions(+), 80 deletions(-) create mode 100644 src/main/migrations/0019_shielded_note_nullifier.ts create mode 100644 src/main/platform/operations/shielded/reads/checkNullifiers.ts create mode 100644 src/main/src/api/shielded/refreshShieldedSpentNotes.ts create mode 100644 tests/unit/shieldedSpentRefresh.test.ts diff --git a/src/main/migrations/0019_shielded_note_nullifier.ts b/src/main/migrations/0019_shielded_note_nullifier.ts new file mode 100644 index 00000000..6d1d733a --- /dev/null +++ b/src/main/migrations/0019_shielded_note_nullifier.ts @@ -0,0 +1,19 @@ +import type {Knex} from 'knex' + +// A note's nullifier is derived from the seed, so only a sync can compute it. +// Persisting it lets a locked wallet ask the chain whether its notes are still +// spendable — decoding a note needs the seed, checking one does not. +// +// Nullable: rows written before this ran have none until the next sync. + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('shielded_notes', table => { + table.binary('nullifier').nullable() + }) +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('shielded_notes', table => { + table.dropColumn('nullifier') + }) +} diff --git a/src/main/platform/PlatformService.ts b/src/main/platform/PlatformService.ts index 3376ff8b..a7ffe132 100644 --- a/src/main/platform/PlatformService.ts +++ b/src/main/platform/PlatformService.ts @@ -19,6 +19,7 @@ import {identityInfos} from './operations/identity/infos' import {identityScan} from './operations/identity/scan' import {identityNonce} from './operations/identity/nonce' import {identityWithdrawal} from './operations/identity/withdrawal' +import {checkNullifiers} from './operations/shielded/reads/checkNullifiers' import {encryptedNotes} from './operations/shielded/reads/encryptedNotes' import {notesCount} from './operations/shielded/reads/notesCount' import {poolInfo} from './operations/shielded/reads/poolInfo' @@ -217,6 +218,7 @@ export class PlatformService { case 'poolInfo': return poolInfo(ctx) case 'notesCount': return notesCount(ctx) case 'encryptedNotes': return encryptedNotes(request.payload, ctx) + case 'checkNullifiers': return checkNullifiers(request.payload, ctx) } } diff --git a/src/main/platform/constants.ts b/src/main/platform/constants.ts index 114124d9..5c0ff2c4 100644 --- a/src/main/platform/constants.ts +++ b/src/main/platform/constants.ts @@ -79,6 +79,7 @@ export function laneFor(request: PlatformRequestMessage): string | null { case 'poolInfo': case 'notesCount': case 'encryptedNotes': + case 'checkNullifiers': return null } } diff --git a/src/main/platform/operations/shielded/reads/checkNullifiers.ts b/src/main/platform/operations/shielded/reads/checkNullifiers.ts new file mode 100644 index 00000000..2dd22257 --- /dev/null +++ b/src/main/platform/operations/shielded/reads/checkNullifiers.ts @@ -0,0 +1,14 @@ +import {PlatformOperations} from '../../../types/messages' +import {OperationContext} from '../../types' + +type Payload = PlatformOperations['checkNullifiers']['payload'] +type Result = PlatformOperations['checkNullifiers']['result'] + +// Matched by nullifier rather than array position: the response order is not +// contractual. +export async function checkNullifiers(payload: Payload, ctx: OperationContext): Promise { + if (payload.nullifiers.length === 0) return {spent: []} + + const statuses = await ctx.sdk.shielded.getShieldedNullifiers(payload.nullifiers) + return {spent: statuses.filter(status => status.isSpent).map(status => status.nullifier)} +} diff --git a/src/main/platform/operations/shielded/spend/spend.ts b/src/main/platform/operations/shielded/spend/spend.ts index b74bcbe5..472a511b 100644 --- a/src/main/platform/operations/shielded/spend/spend.ts +++ b/src/main/platform/operations/shielded/spend/spend.ts @@ -1,5 +1,5 @@ -import {IdentityCreateFromShieldedPoolTransitionWASM} from 'pshenmic-dpp' -import {maxSpendableCredits, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' +import {IdentityCreateFromShieldedPoolTransitionWASM, RecoveredNoteWASM} from 'pshenmic-dpp' +import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' import {PlatformOperations} from '../../../types/messages' import {OperationContext, OperationError, throwIfAborted} from '../../types' import {consensusMessage} from '../../consensusMessage' @@ -20,25 +20,27 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise all[note.index]?.index ?? note.index + const recovered = sdk.shielded.recoverNotes(all, seed, SHIELDED_ACCOUNT) throwIfAborted(signal) // Before proving, not after: a proof costs seconds and the nullifier query // one round trip. const checked = await checkSpent(sdk, recovered) - const stale = checked.filter(({spent}) => spent).map(({recoveredNote}) => recoveredNote.index) + const stale = checked.filter(({spent}) => spent).map(({recoveredNote}) => poolIndex(recoveredNote)) if (stale.length > 0) ctx.notesSpent(stale) - const unspent = checked.filter(({spent}) => !spent).map(({recoveredNote}) => recoveredNote) - const available = payload.noteIndexes != null - ? unspent.filter(note => payload.noteIndexes!.includes(note.index)) - : unspent - const fee = (numSpends: number): bigint => minimumFee(kind, numSpends) - const selectable = available.map(note => ({index: note.index, value: note.note.value})) - const selection = selectSpendNotes(selectable, amount, MAX_SPEND_NOTES, fee) + const selectable = selectableNotes( + checked.map(({recoveredNote, spent}) => ({index: poolIndex(recoveredNote), value: recoveredNote.note.value, spent})), + payload.source, + ) + const selection = selectSpendNotes(selectable, amount, MAX_SPEND_NOTES, fee, payload.source) if (selection == null) { - const max = maxSpendableCredits(selectable, MAX_SPEND_NOTES, fee) + const max = maxSpendableCredits(selectable, MAX_SPEND_NOTES, fee, payload.source) throw new OperationError( `Amount plus the network fee exceeds what ${MAX_SPEND_NOTES} notes can cover; the most spendable now is ${max} credits`, 'insufficientFunds', @@ -46,7 +48,9 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise note.index)) - const toSpend = available.filter(note => selected.has(note.index)) + const toSpend = checked + .filter(({recoveredNote}) => selected.has(poolIndex(recoveredNote))) + .map(({recoveredNote}) => recoveredNote) const {spends, anchor} = sdk.shielded.buildSpendableNotes(all, toSpend) const changeAddress = sdk.keyPair.deriveShieldedAddress(seed, network, SHIELDED_ACCOUNT) @@ -73,7 +77,7 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise note.index)) + ctx.notesSpent(toSpend.map(poolIndex)) return {stHash, identityId, feeCredits: actualFee(stateTransition, kind, amount)} } diff --git a/src/main/platform/operations/shielded/sync.ts b/src/main/platform/operations/shielded/sync.ts index 265a18c7..2b473231 100644 --- a/src/main/platform/operations/shielded/sync.ts +++ b/src/main/platform/operations/shielded/sync.ts @@ -24,6 +24,7 @@ export async function sync(payload: Payload, ctx: OperationContext): Promise b.index - a.index) diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index 945b4aa7..6b6093ad 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -1,6 +1,7 @@ import {NodeStatus} from 'dash-platform-sdk/types.js' import {CoreSpendSource} from '../../src/types/CoinSelection' import {Network} from '../../src/types/Network' +import {ShieldedSpendSource} from '../../src/types/ShieldedNoteSelection' // Wire protocol for the dash-platform utility process. Envelope only — payload // shapes live with their operations. Every terminal event echoes back the @@ -49,6 +50,9 @@ export interface NoteSnapshot { amount: bigint spent: boolean address: string + // Derived from the seed, so only a sync can produce it. Persisted so that a + // locked wallet can still ask the chain whether the note is still spendable. + nullifier: Uint8Array } export interface ShieldSource { @@ -142,8 +146,9 @@ export interface FeeParams { // out which fields it does not use says nothing about the fee. sourceAddress?: string | null identityId?: string | null - // Pool spends only: restricts the spend to one shielded address's notes. - noteIndexes?: number[] | null + // Pool spends only: narrows the spend to one shielded address's notes, or + // names the notes themselves. + shieldedSource?: ShieldedSpendSource | null } // The same params, plus the two numbers only main can supply: how many inputs @@ -196,8 +201,7 @@ export interface PlatformOperations { recipient: string amountCredits: bigint notes: EncryptedNotePayload[] - // Restricts selection to specific pool indexes when the user picked notes. - noteIndexes: number[] | null + source: ShieldedSpendSource | null // identityCreate only. identityIndex: number | null failureAddress: string | null @@ -320,6 +324,12 @@ export interface PlatformOperations { payload: {startIndex: number; count: number} result: {notes: EncryptedNotePayload[]} } + // Which of these notes have since been spent. Takes no seed: the nullifiers + // were derived at sync time, and checking one needs no key. + checkNullifiers: { + payload: {nullifiers: Uint8Array[]} + result: {spent: Uint8Array[]} + } } export type PlatformKind = keyof PlatformOperations diff --git a/src/main/src/WalletBackend.ts b/src/main/src/WalletBackend.ts index 9d7fd8f3..642b6623 100644 --- a/src/main/src/WalletBackend.ts +++ b/src/main/src/WalletBackend.ts @@ -74,6 +74,7 @@ import {GetShieldedPoolInfoHandler} from './api/shielded/getShieldedPoolInfo' import {GetShieldedNotesInfoHandler} from './api/shielded/getShieldedNotesInfo' import {StartShieldedSyncHandler} from './api/shielded/startShieldedSync' import {GetShieldedSyncStateHandler} from './api/shielded/getShieldedSyncState' +import {RefreshShieldedSpentNotesHandler} from './api/shielded/refreshShieldedSpentNotes' import {StartShieldedTransferHandler} from './api/shielded/startShieldedTransfer' import {StartShieldedUnshieldHandler} from './api/shielded/startShieldedUnshield' import {StartShieldedWithdrawalHandler} from './api/shielded/startShieldedWithdrawal' @@ -196,6 +197,7 @@ export class WalletBackend { ipcMain.handle('getShieldedNotesInfo', new GetShieldedNotesInfoHandler(this.shieldedService).handle) ipcMain.handle('startShieldedSync', new StartShieldedSyncHandler(this.shieldedService).handle) ipcMain.handle('getShieldedSyncState', new GetShieldedSyncStateHandler(this.shieldedService).handle) + ipcMain.handle('refreshShieldedSpentNotes', new RefreshShieldedSpentNotesHandler(this.shieldedService).handle) ipcMain.handle('startShieldedTransfer', new StartShieldedTransferHandler(this.shieldedService).handle) ipcMain.handle('startShieldedUnshield', new StartShieldedUnshieldHandler(this.shieldedService).handle) ipcMain.handle('startShieldedWithdrawal', new StartShieldedWithdrawalHandler(this.shieldedService).handle) diff --git a/src/main/src/api/shielded/refreshShieldedSpentNotes.ts b/src/main/src/api/shielded/refreshShieldedSpentNotes.ts new file mode 100644 index 00000000..f60635ca --- /dev/null +++ b/src/main/src/api/shielded/refreshShieldedSpentNotes.ts @@ -0,0 +1,15 @@ +import { IpcMainInvokeEvent } from 'electron/utility' +import {ShieldedService} from '../../services/platform/ShieldedService' +import {ShieldedSyncState} from '../../types/Shielded' + +export class RefreshShieldedSpentNotesHandler { + private shieldedService: ShieldedService + + constructor(shieldedService: ShieldedService) { + this.shieldedService = shieldedService + } + + handle = async (_event: IpcMainInvokeEvent, walletId: string): Promise => { + return this.shieldedService.refreshSpentFlags(walletId) + } +} diff --git a/src/main/src/api/shielded/startShieldedTransfer.ts b/src/main/src/api/shielded/startShieldedTransfer.ts index dfa034fb..c48c8111 100644 --- a/src/main/src/api/shielded/startShieldedTransfer.ts +++ b/src/main/src/api/shielded/startShieldedTransfer.ts @@ -1,4 +1,5 @@ import { IpcMainInvokeEvent } from 'electron/utility' +import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' import {ShieldedService} from '../../services/platform/ShieldedService' import {ShieldedSpendState} from '../../types/Shielded' export class StartShieldedTransferHandler { @@ -8,7 +9,7 @@ export class StartShieldedTransferHandler { this.shieldedService = shieldedService } - handle = async (_event: IpcMainInvokeEvent, walletId: string, recipient: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise => { - return this.shieldedService.startTransfer(walletId, password, recipient, amountCredits, noteIndexes) + handle = async (_event: IpcMainInvokeEvent, walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise => { + return this.shieldedService.startTransfer(walletId, password, recipient, amountCredits, source) } } diff --git a/src/main/src/api/shielded/startShieldedUnshield.ts b/src/main/src/api/shielded/startShieldedUnshield.ts index dde775ed..76d3a288 100644 --- a/src/main/src/api/shielded/startShieldedUnshield.ts +++ b/src/main/src/api/shielded/startShieldedUnshield.ts @@ -1,4 +1,5 @@ import { IpcMainInvokeEvent } from 'electron/utility' +import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' import {ShieldedService} from '../../services/platform/ShieldedService' import {ShieldedSpendState} from '../../types/Shielded' export class StartShieldedUnshieldHandler { @@ -8,7 +9,7 @@ export class StartShieldedUnshieldHandler { this.shieldedService = shieldedService } - handle = async (_event: IpcMainInvokeEvent, walletId: string, outputAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise => { - return this.shieldedService.startUnshield(walletId, password, outputAddress, amountCredits, noteIndexes) + handle = async (_event: IpcMainInvokeEvent, walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise => { + return this.shieldedService.startUnshield(walletId, password, outputAddress, amountCredits, source) } } diff --git a/src/main/src/api/shielded/startShieldedWithdrawal.ts b/src/main/src/api/shielded/startShieldedWithdrawal.ts index 95533ff2..4083fcc8 100644 --- a/src/main/src/api/shielded/startShieldedWithdrawal.ts +++ b/src/main/src/api/shielded/startShieldedWithdrawal.ts @@ -1,4 +1,5 @@ import { IpcMainInvokeEvent } from 'electron/utility' +import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' import {ShieldedService} from '../../services/platform/ShieldedService' import {ShieldedSpendState} from '../../types/Shielded' export class StartShieldedWithdrawalHandler { @@ -8,7 +9,7 @@ export class StartShieldedWithdrawalHandler { this.shieldedService = shieldedService } - handle = async (_event: IpcMainInvokeEvent, walletId: string, coreAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise => { - return this.shieldedService.startWithdrawal(walletId, password, coreAddress, amountCredits, noteIndexes) + handle = async (_event: IpcMainInvokeEvent, walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise => { + return this.shieldedService.startWithdrawal(walletId, password, coreAddress, amountCredits, source) } } diff --git a/src/main/src/database/ShieldedNoteDAO.ts b/src/main/src/database/ShieldedNoteDAO.ts index acba0100..78eb6880 100644 --- a/src/main/src/database/ShieldedNoteDAO.ts +++ b/src/main/src/database/ShieldedNoteDAO.ts @@ -19,7 +19,7 @@ export class ShieldedNoteDAO { getOwnedNotes = async (walletId: string): Promise => { const rows = await this.knex('shielded_notes') - .select('note_index', 'amount', 'address', 'spent') + .select('note_index', 'amount', 'address', 'spent', 'nullifier') .where({wallet_id: walletId}) .orderBy('note_index', 'desc') return rows.map((row) => ({ @@ -27,6 +27,7 @@ export class ShieldedNoteDAO { amount: BigInt(row.amount), address: row.address, spent: Boolean(row.spent), + nullifier: row.nullifier ?? null, })) } @@ -39,9 +40,10 @@ export class ShieldedNoteDAO { amount: n.amount.toString(), address: n.address, spent: n.spent, + nullifier: n.nullifier == null ? null : Buffer.from(n.nullifier), }))) .onConflict(['wallet_id', 'note_index']) - .merge(['amount', 'address', 'spent']) + .merge(['amount', 'address', 'spent', 'nullifier']) } // Only ever an update: a spent index that is not already an owned note would diff --git a/src/main/src/services/platform/ShieldedService.ts b/src/main/src/services/platform/ShieldedService.ts index 3900f3cc..e656a4cc 100644 --- a/src/main/src/services/platform/ShieldedService.ts +++ b/src/main/src/services/platform/ShieldedService.ts @@ -4,6 +4,7 @@ import { Network } from '../../types/Network' import { WalletDAO } from '../../database/WalletDAO' import { IdentityDAO } from '../../database/IdentityDAO' import { ShieldedNoteDAO } from '../../database/ShieldedNoteDAO' +import { PersistNote } from '../../types/ShieldedNote' import { ShieldedPoolDAO } from '../../database/ShieldedPoolDAO' import { ShieldedAddressDAO } from '../../database/ShieldedAddressDAO' import { AssetLockFundingState } from '../../types/AssetLockFunding' @@ -24,7 +25,6 @@ import {Preferences} from '../../preferences' import { lockedDuffsFor, shieldAmountFromLockedDuffs } from '../../utils/assetLockTx' import { PlatformWorkerService } from './PlatformWorkerService' import { - ShieldedNoteInfo, ShieldedPoolInfo, ShieldedNotesInfo, ShieldedSpendPhase, @@ -34,7 +34,8 @@ import { ShieldedSyncState, } from '../../types/Shielded' import {OperationFee} from '../../types/Fee' -import {maxSpendableCredits, selectSpendNotes} from '../../utils/shieldedNoteSelection' +import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' +import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../utils/shieldedNoteSelection' import {requireWallet} from '../../utils/requireWallet' import { EncryptedNotePayload, @@ -46,6 +47,8 @@ import {AssetLockFundingRow, AcquiredAssetLock} from '../../types/AssetLock' type SpendPayload = PlatformPayload<'spend'> +const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex') + function syncPhase(phase: PlatformPhase): ShieldedSyncPhase | null { return phase === 'recovering' ? 'recovering' : null } @@ -350,13 +353,15 @@ export class ShieldedService { } } - private settleSync(state: ShieldedSyncState, notes: ShieldedNoteInfo[]): void { + // Projects away the nullifier: it belongs to the note tables, and the sync + // state is what the renderer polls. + private settleSync(state: ShieldedSyncState, notes: PersistNote[]): void { let balance = 0n for (const note of notes) { if (!note.spent) balance += note.amount } state.balance = balance - state.notes = notes + state.notes = notes.map(({index, amount, spent, address}) => ({index, amount, spent, address})) state.phase = 'done' state.syncedAt = Date.now() } @@ -388,11 +393,12 @@ export class ShieldedService { onProgress: phase => { state.phase = syncPhase(phase) ?? state.phase }, }) - const all: ShieldedNoteInfo[] = result.notes.map(note => ({ + const all: PersistNote[] = result.notes.map(note => ({ index: note.index, amount: note.amount, spent: note.spent, address: note.address, + nullifier: note.nullifier, })).sort((a, b) => b.index - a.index) this.settleSync(state, all) await this.shieldedNoteDAO.upsertNotes(walletId, all) @@ -423,16 +429,16 @@ export class ShieldedService { return this.spendStates.get(walletId) ?? this.idleSpendState() } - startTransfer(walletId: string, password: string, recipient: string, amountCredits: bigint, noteIndexes?: number[]): Promise { - return this.startSpend(walletId, password, 'shieldedTransfer', recipient, amountCredits, noteIndexes) + startTransfer(walletId: string, password: string, recipient: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { + return this.startSpend(walletId, password, 'shieldedTransfer', recipient, amountCredits, source) } - startUnshield(walletId: string, password: string, outputAddress: string, amountCredits: bigint, noteIndexes?: number[]): Promise { - return this.startSpend(walletId, password, 'unshield', outputAddress, amountCredits, noteIndexes) + startUnshield(walletId: string, password: string, outputAddress: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { + return this.startSpend(walletId, password, 'unshield', outputAddress, amountCredits, source) } - startWithdrawal(walletId: string, password: string, coreAddress: string, amountCredits: bigint, noteIndexes?: number[]): Promise { - return this.startSpend(walletId, password, 'shieldedWithdrawal', coreAddress, amountCredits, noteIndexes) + startWithdrawal(walletId: string, password: string, coreAddress: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { + return this.startSpend(walletId, password, 'shieldedWithdrawal', coreAddress, amountCredits, source) } // Returns the in-flight state when a spend is already running for this @@ -447,7 +453,7 @@ export class ShieldedService { return {state, running: false} } - private async startSpend(walletId: string, password: string, kind: PoolSpendOperation, recipient: string, amountCredits: bigint, noteIndexes?: number[]): Promise { + private async startSpend(walletId: string, password: string, kind: PoolSpendOperation, recipient: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { const {state, running} = this.beginSpend(walletId) if (running) return state @@ -468,7 +474,7 @@ export class ShieldedService { recipient, amountCredits, notes, - noteIndexes: noteIndexes ?? null, + source: source ?? null, identityIndex: null, failureAddress: null, coreFeePerByte: coreFeePerByte(this.preferences.general.coreFeeMultiplier), @@ -537,7 +543,7 @@ export class ShieldedService { recipient: '', amountCredits: denominationCredits, notes, - noteIndexes: null, + source: null, identityIndex, failureAddress, coreFeePerByte: coreFeePerByte(this.preferences.general.coreFeeMultiplier), @@ -635,25 +641,27 @@ export class ShieldedService { walletId: string, kind: PoolSpendOperation, amountCredits: bigint, - noteIndexes: number[] | null, + source: ShieldedSpendSource | null, ): Promise { const wallet = await requireWallet(this.walletDAO, walletId) const curve = await this.spendFeeCurve(wallet.network, kind) const feeForCount = (numSpends: number): bigint => curve[Math.min(numSpends, curve.length) - 1] - const candidates = (this.syncStates.get(walletId)?.notes ?? []) - .filter(note => !note.spent && (noteIndexes == null || noteIndexes.includes(note.index))) - .map(note => ({index: note.index, value: note.amount})) + const candidates = selectableNotes( + (this.syncStates.get(walletId)?.notes ?? []) + .map(note => ({index: note.index, value: note.amount, spent: note.spent})), + source, + ) const selection = amountCredits > 0n - ? selectSpendNotes(candidates, amountCredits, curve.length, feeForCount) + ? selectSpendNotes(candidates, amountCredits, curve.length, feeForCount, source) : null return { feeCredits: selection?.feeCredits ?? feeForCount(1), feeDuffs: null, maxDuffs: null, - maxPerTx: maxSpendableCredits(candidates, curve.length, feeForCount), + maxPerTx: maxSpendableCredits(candidates, curve.length, feeForCount, source), noteLimit: curve.length, } } @@ -669,6 +677,26 @@ export class ShieldedService { return feeCredits } + // The chain is the only authority on whether a note is spent, and everything + // before the spend itself reads our own flags. Nullifiers are persisted at + // sync time, so this catches them up without the seed decoding a note needs. + async refreshSpentFlags(walletId: string): Promise { + const wallet = await requireWallet(this.walletDAO, walletId) + const pending = (await this.shieldedNoteDAO.getOwnedNotes(walletId)) + .filter((note): note is PersistNote & {nullifier: Uint8Array} => !note.spent && note.nullifier != null) + if (pending.length === 0) return this.getSyncState(walletId) + + const {spent} = await this.platform.request('checkNullifiers', wallet.network, { + nullifiers: pending.map(note => note.nullifier), + }) + + const spentKeys = new Set(spent.map(hexOf)) + const indexes = pending.filter(note => spentKeys.has(hexOf(note.nullifier))).map(note => note.index) + if (indexes.length > 0) await this.markNotesSpent(walletId, indexes) + + return this.getSyncState(walletId) + } + private async markNotesSpent(walletId: string, indexes: number[]): Promise { await this.shieldedNoteDAO.markSpent(walletId, indexes) const sync = this.syncStates.get(walletId) diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index f1d11dd9..ea7d641a 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -92,7 +92,7 @@ export class FeeService { case 'shieldedWithdrawal': case 'identityCreateFromShielded': requireAutomaticSelection(params.coreSource) - return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.noteIndexes ?? null) + return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.shieldedSource ?? null) // Funded by platform addresses: the fee scales with the inputs, so the // selection has to run before the price is known. diff --git a/src/main/src/types/ShieldedNote.ts b/src/main/src/types/ShieldedNote.ts index a9d5c6bc..95609d21 100644 --- a/src/main/src/types/ShieldedNote.ts +++ b/src/main/src/types/ShieldedNote.ts @@ -3,6 +3,8 @@ export interface PersistNote { amount: bigint address: string spent: boolean + // Null on rows written before 0019; the next sync fills it. + nullifier: Uint8Array | null } export interface EncryptedNoteRecord { diff --git a/src/main/src/types/ShieldedNoteSelection.ts b/src/main/src/types/ShieldedNoteSelection.ts index c21488b6..6b1a15a7 100644 --- a/src/main/src/types/ShieldedNoteSelection.ts +++ b/src/main/src/types/ShieldedNoteSelection.ts @@ -3,10 +3,24 @@ export interface SelectableNote { value: bigint } +// A note as the wallet knows it, before the spent ones are dropped. Both the +// quote and the spend hold this much; only the freshness of `spent` differs. +export interface OwnedNote extends SelectableNote { + spent: boolean +} + +// How a pool spend was restricted to part of the balance. An address narrows +// the notes the automatic selection draws from and still lets it pick; a note +// list is the spend set itself, spent whole, which is the only way a spend can +// consolidate notes an amount would never have reached for. +export type ShieldedSpendSource = + | {kind: 'address'; noteIndexes: number[]} + | {kind: 'notes'; noteIndexes: number[]} + export interface NoteSelectionResult { selected: SelectableNote[] total: bigint feeCredits: bigint } -export type SpendFeeForCount = (numSpends: number) => bigint \ No newline at end of file +export type SpendFeeForCount = (numSpends: number) => bigint diff --git a/src/main/src/utils/index.ts b/src/main/src/utils/index.ts index ba328cbb..0737fe17 100644 --- a/src/main/src/utils/index.ts +++ b/src/main/src/utils/index.ts @@ -20,6 +20,7 @@ import * as migration0015 from '../../migrations/0015_transaction_origin' import * as migration0016 from '../../migrations/0016_input_prevout' import * as migration0017 from '../../migrations/0017_platform_addresses' import * as migration0018 from '../../migrations/0018_shielded_address_rows' +import * as migration0019 from '../../migrations/0019_shielded_note_nullifier' const migrations = [ { name: '0000_init.ts', migration: migration0000 }, @@ -41,6 +42,7 @@ const migrations = [ { name: '0016_input_prevout.ts', migration: migration0016 }, { name: '0017_platform_addresses.ts', migration: migration0017 }, { name: '0018_shielded_address_rows.ts', migration: migration0018 }, + { name: '0019_shielded_note_nullifier.ts', migration: migration0019 }, ] const inlineMigrationSource = { diff --git a/src/main/src/utils/shieldedNoteSelection.ts b/src/main/src/utils/shieldedNoteSelection.ts index 13ac6098..90acf552 100644 --- a/src/main/src/utils/shieldedNoteSelection.ts +++ b/src/main/src/utils/shieldedNoteSelection.ts @@ -1,16 +1,56 @@ -import {NoteSelectionResult, SelectableNote, SpendFeeForCount} from '../types/ShieldedNoteSelection' +import { + NoteSelectionResult, + OwnedNote, + SelectableNote, + ShieldedSpendSource, + SpendFeeForCount, +} from '../types/ShieldedNoteSelection' function byValueDesc(a: SelectableNote, b: SelectableNote): number { if (a.value !== b.value) return a.value > b.value ? -1 : 1 return a.index - b.index } +const totalOf = (notes: SelectableNote[]): bigint => + notes.reduce((sum, note) => sum + note.value, 0n) + +// Every note a spend may draw on. The quote and the spend read their spent +// flags from different places — our bookkeeping and a live nullifier query — +// so this is the one filter that decides what either of them may pick from. +export function selectableNotes( + notes: OwnedNote[], + source?: ShieldedSpendSource | null, +): SelectableNote[] { + const restricted = source == null ? null : new Set(source.noteIndexes) + const selectable = notes + .filter(note => !note.spent) + .filter(note => restricted == null || restricted.has(note.index)) + .map(({index, value}) => ({index, value})) + + // A narrowed spend prices whatever survived, but one that quietly used fewer + // notes than were picked would break the promise picking them makes. + if (source?.kind === 'notes' && selectable.length !== source.noteIndexes.length) { + throw new Error('Selected note is no longer spendable') + } + return selectable +} + export function selectSpendNotes( notes: SelectableNote[], amount: bigint, maxNotes: number, feeForCount: SpendFeeForCount, + source?: ShieldedSpendSource | null, ): NoteSelectionResult | null { + // A picked set is spent whole rather than walked: stopping early would leave + // out notes the user asked to spend, which is the one thing picking them means. + if (source?.kind === 'notes') { + if (notes.length === 0 || notes.length > maxNotes) return null + const total = totalOf(notes) + const feeCredits = feeForCount(notes.length) + return total >= amount + feeCredits ? {selected: [...notes], total, feeCredits} : null + } + const sorted = [...notes].sort(byValueDesc) const selected: SelectableNote[] = [] let total = 0n @@ -28,7 +68,16 @@ export function maxSpendableCredits( notes: SelectableNote[], maxNotes: number, feeForCount: SpendFeeForCount, + source?: ShieldedSpendSource | null, ): bigint { + // No prefix to choose from when every picked note is spent: the price is the + // one the picked count carries, whether or not a smaller set would be cheaper. + if (source?.kind === 'notes') { + if (notes.length === 0 || notes.length > maxNotes) return 0n + const spendable = totalOf(notes) - feeForCount(notes.length) + return spendable > 0n ? spendable : 0n + } + const top = [...notes].sort(byValueDesc).slice(0, maxNotes) let total = 0n let best = 0n diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 787f254b..7b9b646a 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -8,6 +8,12 @@ type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } +// Mirrors src/main/src/types/ShieldedNoteSelection: an address narrows the +// automatic note selection, a picked note list is spent whole. +type ShieldedSpendSource = + | { kind: 'address'; noteIndexes: number[] } + | { kind: 'notes'; noteIndexes: number[] } + export const apiDefinitions = (ipcRenderer) => ({ createWallet: (seedphrase: string, network: Network, password: string) => ipcRenderer.invoke('createWallet', seedphrase, network, password), deleteWallet: (walletId: string) => ipcRenderer.invoke('deleteWallet', walletId), @@ -79,9 +85,10 @@ export const apiDefinitions = (ipcRenderer) => ({ getShieldedNotesInfo: (walletId: string) => ipcRenderer.invoke('getShieldedNotesInfo', walletId), startShieldedSync: (walletId: string, password: string) => ipcRenderer.invoke('startShieldedSync', walletId, password), getShieldedSyncState: (walletId: string) => ipcRenderer.invoke('getShieldedSyncState', walletId), - startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => ipcRenderer.invoke('startShieldedTransfer', walletId, recipient, amountCredits, password, noteIndexes), - startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => ipcRenderer.invoke('startShieldedUnshield', walletId, outputAddress, amountCredits, password, noteIndexes), - startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => ipcRenderer.invoke('startShieldedWithdrawal', walletId, coreAddress, amountCredits, password, noteIndexes), + refreshShieldedSpentNotes: (walletId: string) => ipcRenderer.invoke('refreshShieldedSpentNotes', walletId), + startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedTransfer', walletId, recipient, amountCredits, password, source), + startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedUnshield', walletId, outputAddress, amountCredits, password, source), + startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedWithdrawal', walletId, coreAddress, amountCredits, password, source), startShieldedIdentityCreate: (walletId: string, denominationCredits: bigint, password: string) => ipcRenderer.invoke('startShieldedIdentityCreate', walletId, denominationCredits, password), getShieldedSpendState: (walletId: string) => ipcRenderer.invoke('getShieldedSpendState', walletId), getShieldedAddress: (walletId: string, password?: string) => ipcRenderer.invoke('getShieldedAddress', walletId, password), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index aa2df140..02cefee1 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,6 +10,12 @@ type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } +// Mirrors src/main/src/types/ShieldedNoteSelection: an address narrows the +// automatic note selection, a picked note list is spent whole. +type ShieldedSpendSource = + | { kind: 'address'; noteIndexes: number[] } + | { kind: 'notes'; noteIndexes: number[] } + // Every coin a send can draw on: what getUtxos lists and what an outpoints // source picks from. interface SelectableUtxoDTO { @@ -131,9 +137,10 @@ declare global { getShieldedNotesInfo: (walletId: string) => Promise<{ undecodedCount: number }> startShieldedSync: (walletId: string, password: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> getShieldedSyncState: (walletId: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> - startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> - startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> - startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> + refreshShieldedSpentNotes: (walletId: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> + startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> + startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> + startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> startShieldedIdentityCreate: (walletId: string, denominationCredits: bigint, password: string) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; identityId: string | null; error: string | null }> getShieldedSpendState: (walletId: string) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; identityId: string | null; error: string | null }> getShieldedAddress: (walletId: string, password?: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 1ae8a840..01a3d17e 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,6 +1,6 @@ import { WalletTxDto } from '@renderer/types/WalletTransaction' import { TransferOperation } from '../enums/TransferOperation' -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SelectableUtxo, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' export class API { private static get api() { @@ -191,6 +191,10 @@ export class API { return this.api.getShieldedSyncState(walletId) as Promise } + static async refreshShieldedSpentNotes(walletId: string): Promise { + return this.api.refreshShieldedSpentNotes(walletId) as Promise + } + static async sendPlatformTransfer(walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string): Promise { return this.api.sendPlatformTransfer(walletId, fromAddress, toAddress, amountCredits, password) as Promise } @@ -239,16 +243,16 @@ export class API { return this.api.shieldToPool(walletId, fromAddress, toAddress, amountCredits, password) as Promise } - static async startShieldedTransfer(walletId: string, recipient: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise { - return this.api.startShieldedTransfer(walletId, recipient, amountCredits, password, noteIndexes) as Promise + static async startShieldedTransfer(walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise { + return this.api.startShieldedTransfer(walletId, recipient, amountCredits, password, source) as Promise } - static async startShieldedUnshield(walletId: string, outputAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise { - return this.api.startShieldedUnshield(walletId, outputAddress, amountCredits, password, noteIndexes) as Promise + static async startShieldedUnshield(walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise { + return this.api.startShieldedUnshield(walletId, outputAddress, amountCredits, password, source) as Promise } - static async startShieldedWithdrawal(walletId: string, coreAddress: string, amountCredits: bigint, password: string, noteIndexes?: number[]): Promise { - return this.api.startShieldedWithdrawal(walletId, coreAddress, amountCredits, password, noteIndexes) as Promise + static async startShieldedWithdrawal(walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise { + return this.api.startShieldedWithdrawal(walletId, coreAddress, amountCredits, password, source) as Promise } static async startShieldedIdentityCreate(walletId: string, denominationCredits: bigint, password: string): Promise { diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index 8142bf6c..a706ad0e 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -58,6 +58,12 @@ export type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: Outpoint[] } +// An address narrows the automatic note selection; a picked note list is the +// spend set itself, spent whole. +export type ShieldedSpendSource = + | { kind: 'address'; noteIndexes: number[] } + | { kind: 'notes'; noteIndexes: number[] } + // getUtxos — every coin a send can draw on, which is also everything an // outpoints source may pick from. export interface SelectableUtxo { @@ -83,8 +89,8 @@ export interface FeeParams { // Optional because most operations read none of them. sourceAddress?: string | null identityId?: string | null - // Restricts a pool spend to one shielded address's notes. - noteIndexes?: number[] | null + // Narrows a pool spend to one shielded address's notes, or names the notes. + shieldedSource?: ShieldedSpendSource | null } // feeDuffs is what L1 charges on top of the amount, feeCredits what L2 takes diff --git a/src/renderer/src/components/pages/identities/Registration.tsx b/src/renderer/src/components/pages/identities/Registration.tsx index 8e4dbab3..1851003e 100644 --- a/src/renderer/src/components/pages/identities/Registration.tsx +++ b/src/renderer/src/components/pages/identities/Registration.tsx @@ -135,7 +135,7 @@ export default function IdentityRegistration(): React.JSX.Element { amountDuffs: fromKind === SourceKind.Core ? amountDuffs : null, sourceAddress: selectedSource?.platformAddress ?? null, identityId: null, - noteIndexes: null, + shieldedSource: null, }) // The Core fee is paid on top of the amount, and an L1 registration locks the diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index f854ce20..5b39942c 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -48,7 +48,7 @@ import { ShieldedSpendPhase } from "@renderer/enums/ShieldedSpendPhase"; import { AssetLockFundingPhase } from "@renderer/enums/AssetLockFundingPhase"; import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; -import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, ShieldedSpendState } from "@renderer/api/types"; +import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; import type { SendDraft } from "@renderer/types/SendDraft"; import type { SpecificSourcePreferences } from "@renderer/types/SpecificSource"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; @@ -216,6 +216,12 @@ function WalletTransferHub(): React.JSX.Element { : undefined, [shieldedSpendOperation, useSpecificSource, selectedShieldedAddress, spendableNotes], ) + const shieldedSpendSource = useMemo( + (): ShieldedSpendSource | undefined => shieldedSpecificNotes == null + ? undefined + : {kind: 'address', noteIndexes: shieldedSpecificNotes.map(note => note.index)}, + [shieldedSpecificNotes], + ) const balanceDuffs = coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount const shieldedBalance = shieldedSync.phase === ShieldedSyncPhase.Done && shieldedSync.balance !== null ? BigInt(shieldedSync.balance) : null @@ -248,7 +254,7 @@ function WalletTransferHub(): React.JSX.Element { coreSource: coreSpendSource ?? null, sourceAddress: selectedSource?.platformAddress ?? null, identityId: selectedIdentity?.identifier ?? null, - noteIndexes: shieldedSpecificNotes?.map(note => note.index) ?? null, + shieldedSource: shieldedSpendSource ?? null, }) // An L1 send pays its fee on top of the amount; an L1 -> L2 transfer locks the @@ -676,11 +682,10 @@ function WalletTransferHub(): React.JSX.Element { if (!walletId) { return Promise.resolve({ phase: ShieldedSpendPhase.Error, fetched: 0, total: 0, stHash: null, identityId: null, error: 'No wallet selected' }) } - const noteIndexes = shieldedSpecificNotes?.map(note => note.index) - if (operation === TransferOperation.ShieldedTransfer) return API.startShieldedTransfer(walletId, trimmedTo, amountCredits, password, noteIndexes) - if (operation === TransferOperation.Unshield) return API.startShieldedUnshield(walletId, trimmedTo, amountCredits, password, noteIndexes) + if (operation === TransferOperation.ShieldedTransfer) return API.startShieldedTransfer(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) + if (operation === TransferOperation.Unshield) return API.startShieldedUnshield(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) if (operation === TransferOperation.IdentityCreateFromShielded) return API.startShieldedIdentityCreate(walletId, amountCredits, password) - return API.startShieldedWithdrawal(walletId, trimmedTo, amountCredits, password, noteIndexes) + return API.startShieldedWithdrawal(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) } const runPlatformOperation = (password: string) => { diff --git a/src/renderer/src/hooks/useOperationFee.ts b/src/renderer/src/hooks/useOperationFee.ts index 43fbb4a5..0117a8fe 100644 --- a/src/renderer/src/hooks/useOperationFee.ts +++ b/src/renderer/src/hooks/useOperationFee.ts @@ -12,9 +12,9 @@ export function useOperationFee( operation: TransferOperation | null, params: OperationFeeParams, ): OperationFee & { loading: boolean; err: string | null } { - const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, noteIndexes } = params + const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, shieldedSource } = params - const noteKey = noteIndexes?.join(',') ?? '' + const noteKey = shieldedSource == null ? '' : `${shieldedSource.kind}:${shieldedSource.noteIndexes.join(',')}` const coreSourceKey = coreSource == null ? '' : coreSource.kind === 'address' @@ -24,10 +24,10 @@ export function useOperationFee( const pending = useMemo( () => { if (walletId === null || operation === null || !destinationValid) return null - const feeParams = { amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, noteIndexes } + const feeParams = { amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, shieldedSource } return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${coreSourceKey}:${sourceAddress}:${identityId}:${noteKey}` } }, - // noteIndexes and coreSource are keyed by their string forms: a fresh array + // shieldedSource and coreSource are keyed by their string forms: a fresh array // or object holding the same pick is the same quote, and re-running on // identity would re-ask on every render. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index bf091854..fe03f81f 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -7,6 +7,7 @@ import {PlatformWorkerService} from '../../src/main/src/services/platform/Platfo import {Preferences} from '../../src/main/src/preferences' import {FeeOperation, FeeParams} from '../../src/main/platform/types/messages' import {PlatformSourceCandidate} from '../../src/main/src/types/PlatformTransfer' +import {ShieldedSpendSource} from '../../src/main/src/types/ShieldedNoteSelection' import {FeeQuoteParams} from '../../src/main/platform/types/messages' import {Script} from 'dash-core-sdk' import {AddressDAO} from '../../src/main/src/database/AddressDAO' @@ -77,7 +78,7 @@ function service(candidates: PlatformSourceCandidate[] = [], utxos: UTXO[] = []) } function params(overrides: Partial = {}): FeeParams { - return {amountCredits: 1_000_000n, recipient: 'tdash1qrecipient', sourceAddress: null, identityId: IDENTITY, noteIndexes: null, ...overrides} + return {amountCredits: 1_000_000n, recipient: 'tdash1qrecipient', sourceAddress: null, identityId: IDENTITY, shieldedSource: null, ...overrides} } function feeCalls(request: ReturnType): Array<{operation: string; params: FeeQuoteParams}> { @@ -159,8 +160,9 @@ describe('estimateFee', () => { const operations: FeeOperation[] = ['shieldedTransfer', 'unshield', 'shieldedWithdrawal', 'identityCreateFromShielded'] for (const operation of operations) { const {service: svc, estimateSpendFee} = service() - const fee = await svc.estimateFee(WALLET, operation, params({noteIndexes: [2, 5]})) - expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, operation, 1_000_000n, [2, 5]) + const source: ShieldedSpendSource = {kind: 'address', noteIndexes: [2, 5]} + const fee = await svc.estimateFee(WALLET, operation, params({shieldedSource: source})) + expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, operation, 1_000_000n, source) expect(fee).toEqual({feeCredits: 7n, feeDuffs: null, maxDuffs: null, maxPerTx: 90n, noteLimit: 6}) } }) diff --git a/tests/unit/shieldedNoteSelection.test.ts b/tests/unit/shieldedNoteSelection.test.ts index fced31dd..e3d297d2 100644 --- a/tests/unit/shieldedNoteSelection.test.ts +++ b/tests/unit/shieldedNoteSelection.test.ts @@ -1,12 +1,18 @@ import { describe, it, expect } from 'vitest' -import {maxSpendableCredits, selectSpendNotes} from '../../src/main/src/utils/shieldedNoteSelection' -import {SelectableNote} from '../../src/main/src/types/ShieldedNoteSelection' +import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../src/main/src/utils/shieldedNoteSelection' +import {OwnedNote, SelectableNote, ShieldedSpendSource} from '../../src/main/src/types/ShieldedNoteSelection' function note(index: number, value: bigint): SelectableNote { return { index, value } } const noFee = (): bigint => 0n +function owned(index: number, value: bigint, spent = false): OwnedNote { + return { index, value, spent } +} + +const picked = (...noteIndexes: number[]): ShieldedSpendSource => ({ kind: 'notes', noteIndexes }) + describe('selectSpendNotes', () => { it('selects a single note covering the target', () => { const res = selectSpendNotes([note(0, 100n), note(1, 10n)], 50n, 6, noFee) @@ -89,3 +95,96 @@ describe('maxSpendableCredits', () => { expect(maxSpendableCredits([note(0, 100n), note(1, 5n)], 6, fee)).toBe(50n) }) }) + +describe('selectableNotes', () => { + it('drops notes the wallet already spent', () => { + const notes = [owned(0, 100n), owned(1, 50n, true), owned(2, 30n)] + + expect(selectableNotes(notes).map(n => n.index)).toEqual([0, 2]) + }) + + it('narrows to the notes an address source names', () => { + const notes = [owned(0, 100n), owned(1, 50n), owned(2, 30n)] + + expect(selectableNotes(notes, {kind: 'address', noteIndexes: [1, 2]}).map(n => n.index)).toEqual([1, 2]) + }) + + // The address source only says where to draw from, so a spent note there is + // one fewer candidate rather than a refusal. + it('accepts an address source whose notes were partly spent', () => { + const notes = [owned(0, 100n), owned(1, 50n, true)] + + expect(selectableNotes(notes, {kind: 'address', noteIndexes: [0, 1]}).map(n => n.index)).toEqual([0]) + }) + + it('refuses a picked note that was spent since it was picked', () => { + const notes = [owned(0, 100n), owned(1, 50n, true)] + + expect(() => selectableNotes(notes, picked(0, 1))).toThrow('no longer spendable') + }) + + it('refuses a picked note the wallet does not hold', () => { + expect(() => selectableNotes([owned(0, 100n)], picked(0, 7))).toThrow('no longer spendable') + }) +}) + +describe('spending a picked set of notes', () => { + // The automatic walk would have stopped at the first note covering the amount, + // which is the one thing a picked set must not do. + it('spends every picked note even when one of them would have covered the amount', () => { + const res = selectSpendNotes([note(0, 100n), note(1, 50n)], 20n, 6, noFee, picked(0, 1)) + + expect(res!.selected.map(n => n.index)).toEqual([0, 1]) + expect(res!.total).toBe(150n) + }) + + it('prices the whole pick, including a note the walk would have skipped', () => { + const fee = (count: number): bigint => BigInt(count) * 10n + const res = selectSpendNotes([note(0, 100n), note(1, 1n)], 20n, 6, fee, picked(0, 1)) + + expect(res!.feeCredits).toBe(20n) + }) + + it('returns null when the pick cannot cover the amount and its fee', () => { + const fee = (count: number): bigint => BigInt(count) * 10n + + expect(selectSpendNotes([note(0, 30n)], 25n, 6, fee, picked(0))).toBeNull() + }) + + it('returns null for a pick larger than the note limit', () => { + const notes = Array.from({ length: 7 }, (_, i) => note(i, 10n)) + + expect(selectSpendNotes(notes, 10n, 6, noFee, picked(0, 1, 2, 3, 4, 5, 6))).toBeNull() + }) + + it('returns null for an empty pick', () => { + expect(selectSpendNotes([], 1n, 6, noFee, {kind: 'notes', noteIndexes: []})).toBeNull() + }) +}) + +describe('the most a picked set can spend', () => { + it('is the picked total less the fee that count carries', () => { + const fee = (count: number): bigint => BigInt(count) * 10n + + expect(maxSpendableCredits([note(0, 100n), note(1, 1n)], 6, fee, picked(0, 1))).toBe(81n) + }) + + it('leaves nothing over when the whole amount is spent', () => { + const fee = (count: number): bigint => BigInt(count) * 10n + const notes = [note(0, 100n), note(1, 1n)] + const max = maxSpendableCredits(notes, 6, fee, picked(0, 1)) + const res = selectSpendNotes(notes, max, 6, fee, picked(0, 1)) + + expect(res!.total - max - res!.feeCredits).toBe(0n) + }) + + it('is zero when the pick exceeds the note limit', () => { + const notes = Array.from({ length: 7 }, (_, i) => note(i, 10n)) + + expect(maxSpendableCredits(notes, 6, noFee, picked(0, 1, 2, 3, 4, 5, 6))).toBe(0n) + }) + + it('is zero when the fee outruns the picked total', () => { + expect(maxSpendableCredits([note(0, 5n)], 6, () => 10n, picked(0))).toBe(0n) + }) +}) diff --git a/tests/unit/shieldedSpentRefresh.test.ts b/tests/unit/shieldedSpentRefresh.test.ts new file mode 100644 index 00000000..34344f48 --- /dev/null +++ b/tests/unit/shieldedSpentRefresh.test.ts @@ -0,0 +1,81 @@ +import {describe, expect, it, vi} from 'vitest' +import {ShieldedService} from '../../src/main/src/services/platform/ShieldedService' +import {Preferences} from '../../src/main/src/preferences' +import {PersistNote} from '../../src/main/src/types/ShieldedNote' + +const WALLET = 'wallet-1' + +const nullifier = (byte: number): Uint8Array => new Uint8Array(32).fill(byte) + +const note = (index: number, overrides: Partial = {}): PersistNote => ({ + index, + amount: 100n, + address: `tdash1address${index}`, + spent: false, + nullifier: nullifier(index), + ...overrides, +}) + +function service(notes: PersistNote[], spent: Uint8Array[]) { + const markSpent = vi.fn(async () => {}) + const request = vi.fn(async () => ({spent})) + const shieldedNoteDAO = {getOwnedNotes: async () => notes, markSpent} + const walletDAO = {getWalletById: async () => ({walletId: WALLET, network: 'testnet'})} + + const svc = new ShieldedService( + walletDAO as never, + null as never, + shieldedNoteDAO as never, + null as never, + null as never, + {request} as never, + null as never, + Preferences.default(), + ) + return {svc, request, markSpent} +} + +describe('ShieldedService.refreshSpentFlags', () => { + it('marks the notes the chain reports as spent', async () => { + const {svc, markSpent} = service([note(1), note(2), note(3)], [nullifier(2)]) + + await svc.refreshSpentFlags(WALLET) + + expect(markSpent).toHaveBeenCalledWith(WALLET, [2]) + }) + + it('writes nothing when every note is still spendable', async () => { + const {svc, markSpent} = service([note(1), note(2)], []) + + await svc.refreshSpentFlags(WALLET) + + expect(markSpent).not.toHaveBeenCalled() + }) + + // The check is what makes a locked wallet able to catch up, so nothing about + // it may reach for a seed or a password. + it('asks the worker without a seed', async () => { + const {svc, request} = service([note(1)], []) + + await svc.refreshSpentFlags(WALLET) + + expect(request).toHaveBeenCalledWith('checkNullifiers', 'testnet', {nullifiers: [nullifier(1)]}) + }) + + it('leaves out notes already known to be spent', async () => { + const {svc, request} = service([note(1, {spent: true}), note(2)], []) + + await svc.refreshSpentFlags(WALLET) + + expect(request).toHaveBeenCalledWith('checkNullifiers', 'testnet', {nullifiers: [nullifier(2)]}) + }) + + // Rows written before the migration carry none, and a sync is what fills them. + it('skips notes with no stored nullifier rather than querying for null', async () => { + const {svc, request} = service([note(1, {nullifier: null})], []) + + await svc.refreshSpentFlags(WALLET) + + expect(request).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/transitionFee.test.ts b/tests/unit/transitionFee.test.ts index f80e0e77..7bb1ea98 100644 --- a/tests/unit/transitionFee.test.ts +++ b/tests/unit/transitionFee.test.ts @@ -87,7 +87,7 @@ function params(overrides: Partial = {}): FeeQuoteParams { recipient: IDENTITY, sourceAddress: null, identityId: IDENTITY, - noteIndexes: null, + shieldedSource: null, inputCount: 1, coreFeePerByte: 1, ...overrides, From 00388b821eb83c434144e5647c634ac55d9ba316 Mon Sep 17 00:00:00 2001 From: owl352 Date: Mon, 31 Aug 2026 23:18:02 +0300 Subject: [PATCH 08/31] platform addresses coin control + ui demo in/out picker --- .../operations/address/createIdentity.ts | 4 +- .../platform/operations/address/signInputs.ts | 11 +- .../operations/address/topUpIdentity.ts | 4 +- .../platform/operations/address/transfer.ts | 18 +- .../platform/operations/address/withdrawal.ts | 4 +- src/main/platform/operations/fee.ts | 4 +- src/main/platform/types/messages.ts | 20 +- .../api/wallet/createIdentityFromAddresses.ts | 5 +- .../src/api/wallet/sendPlatformTransfer.ts | 9 +- .../api/wallet/topUpIdentityFromAddresses.ts | 5 +- .../src/api/wallet/withdrawPlatformCredits.ts | 5 +- src/main/src/constants/credits.ts | 4 + .../platform/PlatformTransferService.ts | 90 +++-- src/main/src/services/wallet/FeeService.ts | 65 ++- src/main/src/types/PlatformTransfer.ts | 28 ++ src/main/src/utils/platformFeeStrategy.ts | 48 +++ src/main/src/utils/platformTransfer.ts | 254 +++++++++--- src/preload/definitions.ts | 21 +- src/preload/index.d.ts | 21 +- src/renderer/src/api/index.ts | 18 +- src/renderer/src/api/types.ts | 29 +- .../pages/identities/Registration.tsx | 13 +- .../pages/transfer/EndpointPicker.tsx | 5 +- .../pages/transfer/PlatformInputPicker.tsx | 111 ++++++ .../pages/transfer/PlatformRecipientsTest.tsx | 65 +++ .../components/pages/transfer/TransferHub.tsx | 129 +++++- src/renderer/src/constants/platform.ts | 5 + src/renderer/src/hooks/useOperationFee.ts | 14 +- src/renderer/src/types/SpecificSource.ts | 2 +- src/renderer/src/utils/specificSource.ts | 11 + tests/unit/estimateFee.test.ts | 16 +- tests/unit/platformTransfer.test.ts | 369 +++++++++++++++++- tests/unit/sendDraft.test.ts | 1 + tests/unit/specificSource.test.ts | 16 +- tests/unit/transitionFee.test.ts | 23 +- 35 files changed, 1238 insertions(+), 209 deletions(-) create mode 100644 src/main/src/utils/platformFeeStrategy.ts create mode 100644 src/renderer/src/components/pages/transfer/PlatformInputPicker.tsx create mode 100644 src/renderer/src/components/pages/transfer/PlatformRecipientsTest.tsx create mode 100644 src/renderer/src/constants/platform.ts diff --git a/src/main/platform/operations/address/createIdentity.ts b/src/main/platform/operations/address/createIdentity.ts index 27e96bb7..8c8bdd67 100644 --- a/src/main/platform/operations/address/createIdentity.ts +++ b/src/main/platform/operations/address/createIdentity.ts @@ -2,7 +2,7 @@ import {IdentityCreateFromAddressesTransitionWASM, IdentityPublicKeyInCreationWA import {PlatformOperations} from '../../types/messages' import {OperationContext, OperationError} from '../types' import {broadcast} from '../broadcast' -import {DEDUCT_FROM_FIRST, signInputs, toInputAddresses} from './signInputs' +import {signInputs, toFeeStrategy, toInputAddresses} from './signInputs' import {KEY_SPECS} from '../../constants' type Payload = PlatformOperations['identityCreateFromAddresses']['payload'] @@ -32,7 +32,7 @@ export async function identityCreateFromAddresses(payload: Payload, ctx: Operati publicKeys: keys.map(key => new IdentityPublicKeyInCreationWASM(key.keyId, key.spec.purpose, key.spec.securityLevel, 'ECDSA_SECP256K1', false, key.publicKey)), inputs: toInputAddresses(inputs), - feeStrategy: DEDUCT_FROM_FIRST, + feeStrategy: toFeeStrategy(payload.feeStrategy), inputWitness: [], userFeeIncrease: 0, }) diff --git a/src/main/platform/operations/address/signInputs.ts b/src/main/platform/operations/address/signInputs.ts index 8343de49..dec95d4a 100644 --- a/src/main/platform/operations/address/signInputs.ts +++ b/src/main/platform/operations/address/signInputs.ts @@ -3,13 +3,22 @@ import {AddressWitnessWASM, InputAddressWASM, AddressFundsFeeStrategyStepWASM} f import {Network} from '../../../src/types/Network' import {PLATFORM_ACCOUNT} from '../../../src/constants/addresses' import {AddressInput} from '../../types/messages' +import {FeeStrategyStep} from '../../../src/types/PlatformTransfer' -// Fees come out of the first input for every address-funded transition. +// What a quote charges, and the wallet's default: the fee comes out of the +// first input. A priced transition has no strategy of its own to carry. export const DEDUCT_FROM_FIRST = [AddressFundsFeeStrategyStepWASM.DeductFromInput(0)] export const toInputAddresses = (inputs: AddressInput[]): InputAddressWASM[] => inputs.map(input => new InputAddressWASM(input.platformAddress, input.nonce + 1, input.credits)) +// Both indexes are positions in the inputs and outputs as this transition +// submits them, which main resolved them against. +export const toFeeStrategy = (steps: FeeStrategyStep[]): AddressFundsFeeStrategyStepWASM[] => + steps.map(step => step.kind === 'deductFromInput' + ? AddressFundsFeeStrategyStepWASM.DeductFromInput(step.index) + : AddressFundsFeeStrategyStepWASM.ReduceOutput(step.index)) + export async function signInputs( sdk: DashPlatformSDK, signable: Uint8Array, diff --git a/src/main/platform/operations/address/topUpIdentity.ts b/src/main/platform/operations/address/topUpIdentity.ts index d704de2c..f4a1a2e4 100644 --- a/src/main/platform/operations/address/topUpIdentity.ts +++ b/src/main/platform/operations/address/topUpIdentity.ts @@ -2,7 +2,7 @@ import {IdentityTopUpFromAddressesTransitionWASM} from 'dash-platform-sdk/types. import {PlatformOperations} from '../../types/messages' import {OperationContext} from '../types' import {broadcast} from '../broadcast' -import {DEDUCT_FROM_FIRST, signInputs, toInputAddresses} from './signInputs' +import {signInputs, toFeeStrategy, toInputAddresses} from './signInputs' type Payload = PlatformOperations['identityTopUpFromAddresses']['payload'] type Result = PlatformOperations['identityTopUpFromAddresses']['result'] @@ -15,7 +15,7 @@ export async function identityTopUpFromAddresses(payload: Payload, ctx: Operatio const unsigned = sdk.platformAddresses.createStateTransition('identityTopUpFromAddresses', { identityId: identifier, inputs: toInputAddresses(inputs), - feeStrategy: DEDUCT_FROM_FIRST, + feeStrategy: toFeeStrategy(payload.feeStrategy), inputWitness: [], userFeeIncrease: 0, }) diff --git a/src/main/platform/operations/address/transfer.ts b/src/main/platform/operations/address/transfer.ts index e18ee489..8bf232e3 100644 --- a/src/main/platform/operations/address/transfer.ts +++ b/src/main/platform/operations/address/transfer.ts @@ -2,30 +2,32 @@ import {AddressFundsTransferTransitionWASM, OutputAddressWASM} from 'dash-platfo import {PlatformOperations} from '../../types/messages' import {OperationContext, OperationError} from '../types' import {broadcast} from '../broadcast' -import {DEDUCT_FROM_FIRST, signInputs, toInputAddresses} from './signInputs' +import {signInputs, toFeeStrategy, toInputAddresses} from './signInputs' type Payload = PlatformOperations['addressTransfer']['payload'] type Result = PlatformOperations['addressTransfer']['result'] export async function addressTransfer(payload: Payload, ctx: OperationContext): Promise { const {sdk, network} = ctx - const {seed, input, recipient, amountCredits} = payload + const {seed, inputs, recipients} = payload - if (recipient === input.platformAddress) { - throw new OperationError('Recipient must be different from the source address', 'internal') + // Consensus refuses an output address that is also an input. + const paid = new Set(recipients.map(recipient => recipient.address)) + if (inputs.some(input => paid.has(input.platformAddress))) { + throw new OperationError('A recipient cannot also be one of the addresses funding this transfer', 'internal') } ctx.progress('signing', 0, 0) const unsigned = sdk.platformAddresses.createStateTransition('addressFundsTransfer', { - inputs: toInputAddresses([input]), - feeStrategy: DEDUCT_FROM_FIRST, + inputs: toInputAddresses(inputs), + feeStrategy: toFeeStrategy(payload.feeStrategy), userFeeIncrease: 0, inputWitness: [], - outputs: [new OutputAddressWASM(recipient, amountCredits)], + outputs: recipients.map(recipient => new OutputAddressWASM(recipient.address, recipient.amountCredits)), }) const transition = AddressFundsTransferTransitionWASM.fromStateTransition(unsigned) - transition.inputWitness = await signInputs(sdk, unsigned.getSignableBytes(), [input], seed, network) + transition.inputWitness = await signInputs(sdk, unsigned.getSignableBytes(), inputs, seed, network) return {stHash: await broadcast(sdk, transition.toStateTransition(), ctx)} } diff --git a/src/main/platform/operations/address/withdrawal.ts b/src/main/platform/operations/address/withdrawal.ts index 4ebf8aee..d236746b 100644 --- a/src/main/platform/operations/address/withdrawal.ts +++ b/src/main/platform/operations/address/withdrawal.ts @@ -3,7 +3,7 @@ import {coreAddressToScript} from '../../../src/utils/coreScript' import {PlatformOperations} from '../../types/messages' import {OperationContext} from '../types' import {broadcast} from '../broadcast' -import {DEDUCT_FROM_FIRST, signInputs, toInputAddresses} from './signInputs' +import {signInputs, toFeeStrategy, toInputAddresses} from './signInputs' type Payload = PlatformOperations['addressWithdrawal']['payload'] type Result = PlatformOperations['addressWithdrawal']['result'] @@ -15,7 +15,7 @@ export async function addressWithdrawal(payload: Payload, ctx: OperationContext) ctx.progress('signing', 0, 0) const unsigned = sdk.platformAddresses.createStateTransition('addressCreditWithdrawal', { inputs: toInputAddresses(inputs), - feeStrategy: DEDUCT_FROM_FIRST, + feeStrategy: toFeeStrategy(payload.feeStrategy), inputWitness: [], userFeeIncrease: 0, coreFeePerByte, diff --git a/src/main/platform/operations/fee.ts b/src/main/platform/operations/fee.ts index f9d0d26a..bba8f9b5 100644 --- a/src/main/platform/operations/fee.ts +++ b/src/main/platform/operations/fee.ts @@ -56,8 +56,10 @@ export function transitionFee(payload: Payload, ctx: OperationContext): Result { function protocolFee(operation: TransitionFeeOperation, params: FeeQuoteParams, ctx: OperationContext): bigint { switch (operation) { + // Consensus meters an input like an output, one address balance write each, + // so every address touched is priced at the output rate, plus one for base. case 'addressFundsTransfer': - return AddressFundsTransferTransitionWASM.estimateMinFee(params.inputCount, paid(params).length) + return AddressFundsTransferTransitionWASM.estimateMinFee(0, params.inputCount + paid(params).length + 1) // What a withdrawal does not spend stays on the address, so no change output. case 'addressWithdrawal': diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index 6b6093ad..48dabb83 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -1,6 +1,7 @@ import {NodeStatus} from 'dash-platform-sdk/types.js' import {CoreSpendSource} from '../../src/types/CoinSelection' import {Network} from '../../src/types/Network' +import {FeeStrategyStep, PlatformSpendSource} from '../../src/types/PlatformTransfer' import {ShieldedSpendSource} from '../../src/types/ShieldedNoteSelection' // Wire protocol for the dash-platform utility process. Envelope only — payload @@ -104,7 +105,6 @@ export type PoolSpendOperation = | 'identityCreateFromShielded' export type TransitionFeeOperation = - | 'addressFundsTransfer' | 'shield' | 'identityToAddress' | 'identityToIdentity' @@ -123,6 +123,7 @@ export type BuiltTransitionOperation = Exclude< // Funded by platform addresses, so the fee scales with the inputs the selection // takes and the two have to resolve together. export type SelectionFeeOperation = + | 'addressFundsTransfer' | 'addressWithdrawal' | 'identityCreate' | 'identityTopUp' @@ -139,12 +140,12 @@ export interface FeeParams { // L1 quotes only: the fee scales with the inputs the amount takes. amountDuffs?: bigint | null // L1 quotes only: narrows the funding to one Core address, or to coins the - // user picked. Kept apart from sourceAddress, which names a platform address + // user picked. Kept apart from platformSource, which names platform addresses // and so matches no L1 coin at all. coreSource?: CoreSpendSource | null // Optional because most operations read none of them, and a caller spelling // out which fields it does not use says nothing about the fee. - sourceAddress?: string | null + platformSource?: PlatformSpendSource | null identityId?: string | null // Pool spends only: narrows the spend to one shielded address's notes, or // names the notes themselves. @@ -251,19 +252,24 @@ export interface PlatformOperations { result: {infos: AddressInfo[]} } addressTransfer: { - payload: {seed: Uint8Array; input: AddressInput; recipient: string; amountCredits: bigint} + payload: { + seed: Uint8Array + inputs: AddressInput[] + feeStrategy: FeeStrategyStep[] + recipients: Recipient[] + } result: {stHash: string} } addressWithdrawal: { - payload: {seed: Uint8Array; inputs: AddressInput[]; coreAddress: string; coreFeePerByte: number} + payload: {seed: Uint8Array; inputs: AddressInput[]; feeStrategy: FeeStrategyStep[]; coreAddress: string; coreFeePerByte: number} result: {stHash: string} } identityCreateFromAddresses: { - payload: {seed: Uint8Array; identityIndex: number; inputs: AddressInput[]} + payload: {seed: Uint8Array; identityIndex: number; inputs: AddressInput[]; feeStrategy: FeeStrategyStep[]} result: {stHash: string; identifier: string} } identityTopUpFromAddresses: { - payload: {seed: Uint8Array; identifier: string; inputs: AddressInput[]} + payload: {seed: Uint8Array; identifier: string; inputs: AddressInput[]; feeStrategy: FeeStrategyStep[]} result: {stHash: string} } identityCreditsToAddresses: { diff --git a/src/main/src/api/wallet/createIdentityFromAddresses.ts b/src/main/src/api/wallet/createIdentityFromAddresses.ts index c488190c..74915d78 100644 --- a/src/main/src/api/wallet/createIdentityFromAddresses.ts +++ b/src/main/src/api/wallet/createIdentityFromAddresses.ts @@ -1,6 +1,7 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { PlatformTransferService } from '../../services/platform/PlatformTransferService' import { IdentityCreateResult } from '../../types/IdentityCreateResult' +import { PlatformSpendSource } from '../../types/PlatformTransfer' export class CreateIdentityFromAddressesHandler { private platformTransferService: PlatformTransferService @@ -12,10 +13,10 @@ export class CreateIdentityFromAddressesHandler { handle = async ( _event: IpcMainInvokeEvent, walletId: string, - fromAddress: string | null, + source: PlatformSpendSource | null, amountCredits: bigint, password: string, ): Promise => { - return this.platformTransferService.createIdentityFromAddresses(walletId, fromAddress, amountCredits, password) + return this.platformTransferService.createIdentityFromAddresses(walletId, source, amountCredits, password) } } diff --git a/src/main/src/api/wallet/sendPlatformTransfer.ts b/src/main/src/api/wallet/sendPlatformTransfer.ts index aa1da26c..d8d30a33 100644 --- a/src/main/src/api/wallet/sendPlatformTransfer.ts +++ b/src/main/src/api/wallet/sendPlatformTransfer.ts @@ -1,6 +1,8 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { PlatformTransferService } from '../../services/platform/PlatformTransferService' import { PlatformSendResult } from '../../types/PlatformSendResult' +import { PlatformSpendSource } from '../../types/PlatformTransfer' +import { Recipient } from '../../../platform/types/messages' export class SendPlatformTransferHandler { private platformTransferService: PlatformTransferService @@ -12,11 +14,10 @@ export class SendPlatformTransferHandler { handle = async ( _event: IpcMainInvokeEvent, walletId: string, - fromAddress: string, - toAddress: string, - amountCredits: bigint, + source: PlatformSpendSource | null, + recipients: Recipient[], password: string, ): Promise => { - return this.platformTransferService.sendPlatformTransfer(walletId, fromAddress, toAddress, amountCredits, password) + return this.platformTransferService.sendPlatformTransfer(walletId, source, recipients, password) } } diff --git a/src/main/src/api/wallet/topUpIdentityFromAddresses.ts b/src/main/src/api/wallet/topUpIdentityFromAddresses.ts index bf022d09..efdf0584 100644 --- a/src/main/src/api/wallet/topUpIdentityFromAddresses.ts +++ b/src/main/src/api/wallet/topUpIdentityFromAddresses.ts @@ -1,6 +1,7 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { PlatformTransferService } from '../../services/platform/PlatformTransferService' import { PlatformSendResult } from '../../types/PlatformSendResult' +import { PlatformSpendSource } from '../../types/PlatformTransfer' export class TopUpIdentityFromAddressesHandler { private platformTransferService: PlatformTransferService @@ -13,10 +14,10 @@ export class TopUpIdentityFromAddressesHandler { _event: IpcMainInvokeEvent, walletId: string, identityId: string, - fromAddress: string | null, + source: PlatformSpendSource | null, amountCredits: bigint, password: string, ): Promise => { - return this.platformTransferService.topUpIdentityFromAddresses(walletId, identityId, fromAddress, amountCredits, password) + return this.platformTransferService.topUpIdentityFromAddresses(walletId, identityId, source, amountCredits, password) } } diff --git a/src/main/src/api/wallet/withdrawPlatformCredits.ts b/src/main/src/api/wallet/withdrawPlatformCredits.ts index 06dda15e..366353e4 100644 --- a/src/main/src/api/wallet/withdrawPlatformCredits.ts +++ b/src/main/src/api/wallet/withdrawPlatformCredits.ts @@ -1,6 +1,7 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { PlatformTransferService } from '../../services/platform/PlatformTransferService' import { PlatformSendResult } from '../../types/PlatformSendResult' +import { PlatformSpendSource } from '../../types/PlatformTransfer' export class WithdrawPlatformCreditsHandler { private platformTransferService: PlatformTransferService @@ -12,11 +13,11 @@ export class WithdrawPlatformCreditsHandler { handle = async ( _event: IpcMainInvokeEvent, walletId: string, - fromAddress: string | null, + source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string, ): Promise => { - return this.platformTransferService.withdrawPlatformToCore(walletId, fromAddress, toCoreAddress, amountCredits, password) + return this.platformTransferService.withdrawPlatformToCore(walletId, source, toCoreAddress, amountCredits, password) } } diff --git a/src/main/src/constants/credits.ts b/src/main/src/constants/credits.ts index c9e650ed..19a81403 100644 --- a/src/main/src/constants/credits.ts +++ b/src/main/src/constants/credits.ts @@ -11,8 +11,12 @@ export const MAX_FEE_MULTIPLIER = 20 export const MIN_OUTPUT_CREDITS = 500_000n export const MIN_INPUT_CREDITS = 100_000n +// Consensus funds a new identity from a lower floor than it accepts as an +// ordinary output. +export const MIN_IDENTITY_FUNDING_CREDITS = 200_000n export const MAX_ADDRESS_INPUTS = 16 export const MAX_RECIPIENTS = 128 +export const MAX_FEE_STRATEGY_STEPS = 4 export const ASSET_LOCK_PAYLOAD_VERSION = 1 export const ASSET_LOCK_CREDIT_OUTPUT_INDEX = 0 diff --git a/src/main/src/services/platform/PlatformTransferService.ts b/src/main/src/services/platform/PlatformTransferService.ts index 637343f2..5f19255f 100644 --- a/src/main/src/services/platform/PlatformTransferService.ts +++ b/src/main/src/services/platform/PlatformTransferService.ts @@ -6,6 +6,7 @@ import {ShieldedService} from './ShieldedService' import {IdentityDAO} from '../../database/IdentityDAO' import {AssetLockFundingState} from '../../types/AssetLockFunding' import {CoreSpendSource} from '../../types/CoinSelection' +import {PlatformInputPlan, PlatformSpendSource} from '../../types/PlatformTransfer' import {Network} from '../../types/Network' import {Wallet} from '../../types/Wallet' import {Identity} from '../../types/Identity' @@ -14,14 +15,15 @@ import {IdentityCreateResult} from '../../types/IdentityCreateResult' import {ShieldResult} from '../../types/ShieldResult' import {unlockWallet, zeroSeed} from '../../utils/walletSeed' import {platformAccountXpub} from '../../utils/platformAddress' -import {CREDITS_PER_DUFF, MAX_RECIPIENTS, MIN_OUTPUT_CREDITS} from '../../constants/credits' +import {CREDITS_PER_DUFF, MIN_IDENTITY_FUNDING_CREDITS} from '../../constants/credits' import {identityPath} from '../../utils/identityKeys' -import {selectPlatformSource, toAddressInput} from '../../utils/platformTransfer' +import {requireRecipients, selectPlatformSource, toAddressInput} from '../../utils/platformTransfer' import {lockedDuffsFor} from '../../utils/assetLockTx' import {coreFeePerByte} from '../../utils/coreFeeRate' import {Preferences} from '../../preferences' import {AcquiredAssetLock, AssetLockFundingRow} from '../../types/AssetLock' import {FeeService} from '../wallet/FeeService' +import {Recipient, SelectionFeeOperation} from '../../../platform/types/messages' // Every way credits move on L2: between platform addresses, to and from @@ -63,39 +65,40 @@ export class PlatformTransferService { this.preferences = preferences } + // One transition pays many addresses: consensus keys its outputs by address, + // and every extra one costs another balance write rather than another fee. async sendPlatformTransfer( walletId: string, - fromPlatformAddress: string, - toPlatformAddress: string, - amountCredits: bigint, + source: PlatformSpendSource | null, + recipients: Recipient[], password: string, ): Promise { - if (amountCredits <= 0n) { - throw new Error('Send amount must be greater than zero') - } + const amountCredits = requireRecipients(recipients) const {wallet, seed} = await this.unlock(walletId, password) const network = wallet.network - const candidates = await this.addresses.loadCandidates(wallet) - const feeCredits = await this.fee.requireFee(walletId, 'addressFundsTransfer', { - amountCredits, recipient: toPlatformAddress, sourceAddress: fromPlatformAddress || null, + const {plan, error} = await this.fee.planInputs(wallet, 'addressFundsTransfer', { + amountCredits, + recipient: recipients.map(recipient => recipient.address), + platformSource: source, }) - const source = selectPlatformSource(candidates, amountCredits, feeCredits, fromPlatformAddress || undefined) + if (plan === null) throw new Error(error) + this.logPlan('addressFundsTransfer', plan, amountCredits) const {stHash} = await this.platform.request('addressTransfer', network, { seed, - input: toAddressInput(source, amountCredits), - recipient: toPlatformAddress, - amountCredits, + inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, + recipients, }) return { stHash, amountCredits, - feeCredits, - fromAddress: source.platformAddress, - toAddress: toPlatformAddress, + feeCredits: plan.feeCredits, + fromAddress: plan.inputs[0].candidate.platformAddress, + toAddress: recipients[0].address, } } @@ -105,20 +108,11 @@ export class PlatformTransferService { recipients: Array<{address: string; amountCredits: bigint}>, password: string, ): Promise { - if (recipients.length === 0 || recipients.length > MAX_RECIPIENTS) { - throw new Error(`Recipient count must be between 1 and ${MAX_RECIPIENTS}`) - } - for (const recipient of recipients) { - if (recipient.amountCredits < MIN_OUTPUT_CREDITS) { - throw new Error(`Minimum amount per recipient is ${MIN_OUTPUT_CREDITS.toString()} credits`) - } - } + const totalCredits = requireRecipients(recipients) const {wallet, seed} = await this.unlock(walletId, password) const network = wallet.network const identity = await this.requireIdentity(walletId, identityIdentifier) - - const totalCredits = recipients.reduce((sum, recipient) => sum + recipient.amountCredits, 0n) const feeCredits = await this.fee.requireFee(walletId, 'identityToAddress', { amountCredits: recipients[0].amountCredits, recipient: recipients.map(entry => entry.address), @@ -184,12 +178,12 @@ export class PlatformTransferService { async createIdentityFromAddresses( walletId: string, - fromPlatformAddress: string | null, + source: PlatformSpendSource | null, amountCredits: bigint, password: string, ): Promise { - if (amountCredits < MIN_OUTPUT_CREDITS) { - throw new Error(`Minimum identity funding is ${MIN_OUTPUT_CREDITS.toString()} credits`) + if (amountCredits < MIN_IDENTITY_FUNDING_CREDITS) { + throw new Error(`Minimum identity funding is ${MIN_IDENTITY_FUNDING_CREDITS.toString()} credits`) } const {wallet, seed} = await this.unlock(walletId, password) @@ -199,14 +193,16 @@ export class PlatformTransferService { const identityIndex = existing.reduce((max, identity) => Math.max(max, identity.identityIndex), -1) + 1 const {plan, error} = await this.fee.planInputs(wallet, 'identityCreate', { - amountCredits, recipient: '', sourceAddress: fromPlatformAddress, + amountCredits, recipient: '', platformSource: source, }) if (plan === null) throw new Error(error) + this.logPlan('identityCreate', plan, amountCredits) const {stHash, identifier} = await this.platform.request('identityCreateFromAddresses', network, { seed, identityIndex, inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, }) await this.identityDAO.insertIdentities([{ @@ -229,7 +225,7 @@ export class PlatformTransferService { async topUpIdentityFromAddresses( walletId: string, identityId: string, - fromPlatformAddress: string | null, + source: PlatformSpendSource | null, amountCredits: bigint, password: string, ): Promise { @@ -244,14 +240,16 @@ export class PlatformTransferService { if (!exists) throw new Error('Identity not found on Platform') const {plan, error} = await this.fee.planInputs(wallet, 'identityTopUp', { - amountCredits, recipient: identityId, sourceAddress: fromPlatformAddress, + amountCredits, recipient: identityId, platformSource: source, }) if (plan === null) throw new Error(error) + this.logPlan('identityTopUp', plan, amountCredits) const {stHash} = await this.platform.request('identityTopUpFromAddresses', network, { seed, identifier: identityId, inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, }) return { @@ -265,7 +263,7 @@ export class PlatformTransferService { async withdrawPlatformToCore( walletId: string, - fromPlatformAddress: string | null, + source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string, @@ -278,13 +276,15 @@ export class PlatformTransferService { const network = wallet.network const {plan, error} = await this.fee.planInputs(wallet, 'addressWithdrawal', { - amountCredits, recipient: toCoreAddress, sourceAddress: fromPlatformAddress, + amountCredits, recipient: toCoreAddress, platformSource: source, }) if (plan === null) throw new Error(error) + this.logPlan('addressWithdrawal', plan, amountCredits) const {stHash} = await this.platform.request('addressWithdrawal', network, { seed, inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, coreAddress: toCoreAddress, coreFeePerByte: coreFeePerByte(this.preferences.general.coreFeeMultiplier), }) @@ -355,7 +355,7 @@ export class PlatformTransferService { const candidates = await this.addresses.loadCandidates(wallet) const feeCredits = await this.fee.requireFee(walletId, 'shield', { - amountCredits, recipient: toShieldedAddress, sourceAddress: fromPlatformAddress || null, + amountCredits, recipient: toShieldedAddress, }) const source = selectPlatformSource(candidates, amountCredits, feeCredits, fromPlatformAddress || undefined) @@ -448,6 +448,22 @@ export class PlatformTransferService { } + // A consensus refusal reports only the figure it required; reconciling it + // needs the count priced and what each address keeps back. + private logPlan(operation: SelectionFeeOperation, plan: PlatformInputPlan, amountCredits: bigint): void { + const inputs = plan.inputs + .map(({candidate, credits}) => `${candidate.platformAddress} spends=${credits} of=${candidate.balanceCredits} nonce=${candidate.nonce}`) + .join(' | ') + const strategy = plan.feeStrategy + .map(step => step.kind === 'deductFromInput' ? `input[${step.index}]` : `output[${step.index}]`) + .join(',') + + console.log( + `[platform] ${operation}: amount=${amountCredits} fee=${plan.feeCredits} ` + + `inputs=${plan.inputs.length} feeFrom=${strategy} | ${inputs}`, + ) + } + private async requireIdentity(walletId: string, identifier: string): Promise { const identities = await this.identityDAO.getIdentitiesByWalletId(walletId) const identity = identities.find(entry => entry.identifier === identifier) diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index ea7d641a..291d3018 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -17,7 +17,13 @@ import { import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' import {requireWallet} from '../../utils/requireWallet' import {maxSelectableAmount, requireAutomaticSelection, selectCoins} from '../../utils/coinSelection' -import {selectPlatformInputsWithFee} from '../../utils/platformTransfer' +import { + PlatformFeeForInputs, + maxPlatformCredits, + requireAutomaticInputs, + selectPlatformInputsWithFee, + selectablePlatformInputs, +} from '../../utils/platformTransfer' import {coreFeeDuffsFor, coreFeePerByte} from '../../utils/coreFeeRate' import {selectableTransferUtxos} from '../../utils/transferInputs' @@ -70,6 +76,7 @@ export class FeeService { // Paid in Dash on L1, per byte, so the quote runs the selection the send // will run rather than a floor the send is free to exceed. case 'coreSend': + requireAutomaticInputs(params.platformSource) return {feeCredits: null, ...await this.coreQuote(wallet, params, 0), maxPerTx: null, noteLimit: null} // Two transactions, so two fees. The L1 lock is paid in Dash on top of the @@ -79,6 +86,7 @@ export class FeeService { case 'assetLockShield': case 'identityRegister': case 'identityTopUpL1': + requireAutomaticInputs(params.platformSource) return { feeCredits: await this.protocolFee(wallet, operation, params, 1), ...await this.coreQuote(wallet, params, ASSET_LOCK_PAYLOAD_BYTES), @@ -92,15 +100,17 @@ export class FeeService { case 'shieldedWithdrawal': case 'identityCreateFromShielded': requireAutomaticSelection(params.coreSource) + requireAutomaticInputs(params.platformSource) return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.shieldedSource ?? null) // Funded by platform addresses: the fee scales with the inputs, so the // selection has to run before the price is known. + case 'addressFundsTransfer': case 'addressWithdrawal': case 'identityCreate': case 'identityTopUp': requireAutomaticSelection(params.coreSource) - return this.credits(await this.selectionFee(wallet, operation, params)) + return this.platformQuote(wallet, operation, params) // Spends an identity's balance, so there is no price until one is picked. // null rather than zero: nothing charges zero. @@ -108,14 +118,15 @@ export class FeeService { case 'identityToIdentity': case 'identityWithdrawal': requireAutomaticSelection(params.coreSource) + requireAutomaticInputs(params.platformSource) return this.credits(params.identityId == null || params.amountCredits <= 0n ? null : await this.protocolFee(wallet, operation, params, 1)) - // One input by construction: neither send ever splits its source. - case 'addressFundsTransfer': + // One input by construction: a shield spends its source address whole. case 'shield': requireAutomaticSelection(params.coreSource) + requireAutomaticInputs(params.platformSource) return this.credits(await this.protocolFee(wallet, operation, params, 1)) } } @@ -137,10 +148,11 @@ export class FeeService { ): Promise { const candidates = await this.addresses.loadCandidates(wallet) return selectPlatformInputsWithFee( - candidates, + selectablePlatformInputs(candidates, params.platformSource), params.amountCredits, - inputCount => this.protocolFee(wallet, operation, params, inputCount), - params.sourceAddress ?? undefined, + this.inputFee(wallet, operation, params), + params.platformSource, + this.outputCount(operation, params), ) } @@ -163,14 +175,43 @@ export class FeeService { } // A quote is asked for before the amount is affordable, so a selection that - // refuses answers with the one-input floor rather than failing. - private async selectionFee( + // refuses still answers, with the floor a single input would cost. + private async platformQuote( wallet: Wallet, operation: SelectionFeeOperation, params: FeeParams, - ): Promise { - const {plan} = await this.planInputs(wallet, operation, params) - return plan?.feeCredits ?? this.protocolFee(wallet, operation, params, 1) + ): Promise { + const candidates = await this.addresses.loadCandidates(wallet) + const selectable = selectablePlatformInputs(candidates, params.platformSource) + const feeForInputs = this.inputFee(wallet, operation, params) + const {plan} = await selectPlatformInputsWithFee( + selectable, params.amountCredits, feeForInputs, params.platformSource, this.outputCount(operation, params)) + + return { + feeCredits: plan?.feeCredits ?? await feeForInputs(1), + feeDuffs: null, + maxDuffs: null, + maxPerTx: await maxPlatformCredits(selectable, feeForInputs, params.platformSource), + noteLimit: null, + } + } + + // A transfer is the one address-funded transition carrying outputs of its + // own, which are all a reduceOutput fee step could index. + private outputCount(operation: SelectionFeeOperation, params: FeeParams): number { + if (operation !== 'addressFundsTransfer') return 0 + return Array.isArray(params.recipient) ? params.recipient.length : 1 + } + + // Every input count is a worker round trip, and the selection and the maximum + // walk the same ones, so a quote prices a count once. + private inputFee(wallet: Wallet, operation: SelectionFeeOperation, params: FeeParams): PlatformFeeForInputs { + const priced = new Map>() + return inputCount => { + const quoted = priced.get(inputCount) ?? this.protocolFee(wallet, operation, params, inputCount) + priced.set(inputCount, quoted) + return quoted + } } // The fee scales with the inputs the selection takes, so the quote runs that diff --git a/src/main/src/types/PlatformTransfer.ts b/src/main/src/types/PlatformTransfer.ts index 2acb46a0..81795ccf 100644 --- a/src/main/src/types/PlatformTransfer.ts +++ b/src/main/src/types/PlatformTransfer.ts @@ -16,8 +16,36 @@ export interface PlatformInputSelection { export interface PlatformInputPlan { inputs: PlatformInputSelection[] feeCredits: bigint + // Indexed against `inputs` as they are ordered here, which is the order the + // transition is built in. + feeStrategy: FeeStrategyStep[] } +// The most of one address a transition may draw. Credits are divisible, so what +// it does not draw stays put — there is no change output to come back to. +export interface PlatformPickedInput { + address: string + credits: bigint +} + +// Who pays the fee, by address: the index consensus resolves is a position in +// the byte-sorted input list, which only main can compute. +export type PlatformFeeStep = + | {kind: 'deductFromInput'; address: string} + | {kind: 'reduceOutput'; index: number} + +// The protocol spelling of the same thing, and what the worker maps onto +// AddressFundsFeeStrategyStepWASM. +export type FeeStrategyStep = + | {kind: 'deductFromInput'; index: number} + | {kind: 'reduceOutput'; index: number} + +// How a transition's funding was restricted: one address to draw from, or every +// address it may draw on, how much of each, and which one is charged. +export type PlatformSpendSource = + | {kind: 'address'; address: string} + | {kind: 'inputs'; inputs: PlatformPickedInput[]; feeStrategy: PlatformFeeStep[]} + // Either the inputs that fund an amount, or why none can. A quote asks before // the amount is affordable, so "cannot fund" is an answer, not a failure. export type PlatformInputOutcome = diff --git a/src/main/src/utils/platformFeeStrategy.ts b/src/main/src/utils/platformFeeStrategy.ts new file mode 100644 index 00000000..ab9aabac --- /dev/null +++ b/src/main/src/utils/platformFeeStrategy.ts @@ -0,0 +1,48 @@ +import {FeeStrategyStep, PlatformFeeStep, PlatformInputSelection} from '../types/PlatformTransfer' +import {MAX_FEE_STRATEGY_STEPS} from '../constants/credits' + +// What every address-funded transition has always sent: the fee comes off +// index 0, which consensus resolves against the byte-sorted inputs. +export const DEDUCT_FROM_FIRST_INPUT: FeeStrategyStep[] = [{kind: 'deductFromInput', index: 0}] + +export type FeeStrategyOutcome = + | {steps: FeeStrategyStep[]; error: null} + | {steps: null; error: string} + +// Outputs are counted rather than named: an operation whose funding has no +// address output refuses a reduceOutput step instead of guessing its index. +export function resolveFeeStrategy( + steps: PlatformFeeStep[], + inputs: PlatformInputSelection[], + outputCount: number, +): FeeStrategyOutcome { + if (steps.length === 0) { + return {steps: null, error: 'Fee strategy must name who pays the fee'} + } + if (steps.length > MAX_FEE_STRATEGY_STEPS) { + return {steps: null, error: `A transition takes at most ${MAX_FEE_STRATEGY_STEPS} fee strategy steps`} + } + + const resolved: FeeStrategyStep[] = [] + for (const step of steps) { + if (step.kind === 'reduceOutput') { + if (step.index < 0 || step.index >= outputCount) { + return {steps: null, error: 'This operation has no output the fee can be taken from'} + } + resolved.push(step) + continue + } + + const index = inputs.findIndex(input => input.candidate.platformAddress === step.address) + if (index === -1) { + return {steps: null, error: 'The address paying the fee is not one of the inputs'} + } + resolved.push({kind: 'deductFromInput', index}) + } + + const targets = new Set(resolved.map(step => `${step.kind}:${step.index}`)) + if (targets.size !== resolved.length) { + return {steps: null, error: 'Fee strategy charges the same input or output twice'} + } + return {steps: resolved, error: null} +} diff --git a/src/main/src/utils/platformTransfer.ts b/src/main/src/utils/platformTransfer.ts index d7781696..c3a1932a 100644 --- a/src/main/src/utils/platformTransfer.ts +++ b/src/main/src/utils/platformTransfer.ts @@ -1,11 +1,33 @@ import { PlatformInputOutcome, - PlatformInputPlan, PlatformInputSelection, PlatformSourceCandidate, + PlatformSpendSource, } from '../types/PlatformTransfer' -import {AddressInput} from '../../platform/types/messages' -import {MAX_ADDRESS_INPUTS, MIN_INPUT_CREDITS, MIN_OUTPUT_CREDITS} from '../constants/credits' +import {AddressInput, Recipient} from '../../platform/types/messages' +import {MAX_ADDRESS_INPUTS, MAX_RECIPIENTS, MIN_INPUT_CREDITS, MIN_OUTPUT_CREDITS} from '../constants/credits' +import {DEDUCT_FROM_FIRST_INPUT, resolveFeeStrategy} from './platformFeeStrategy' + +// The count is only known once the selection stops, and each count is a worker +// round trip, so what crosses is the price of a count rather than a price. +export type PlatformFeeForInputs = (inputCount: number) => Promise + +// Consensus keys outputs by address, so a repeated recipient would be one +// merged payment rather than the two the caller asked for. +export function requireRecipients(recipients: Recipient[]): bigint { + if (recipients.length === 0 || recipients.length > MAX_RECIPIENTS) { + throw new Error(`Recipient count must be between 1 and ${MAX_RECIPIENTS}`) + } + if (new Set(recipients.map(recipient => recipient.address)).size !== recipients.length) { + throw new Error('Each recipient can appear only once') + } + for (const recipient of recipients) { + if (recipient.amountCredits < MIN_OUTPUT_CREDITS) { + throw new Error(`Minimum amount per recipient is ${MIN_OUTPUT_CREDITS.toString()} credits`) + } + } + return recipients.reduce((sum, recipient) => sum + recipient.amountCredits, 0n) +} export function toAddressInput(candidate: PlatformSourceCandidate, credits: bigint): AddressInput { return { @@ -16,33 +38,59 @@ export function toAddressInput(candidate: PlatformSourceCandidate, credits: bigi } } -// Consensus takes the fee from the remaining balance of the input its -// DeductFromInput(0) resolves to, which is whichever address sorts first. -export function selectPlatformInputs( +// A pick names platform addresses, so it matches nothing an L1 send, an identity +// or the pool would spend. Refused rather than silently dropped. +export function requireAutomaticInputs(source?: PlatformSpendSource | null): void { + if (source != null) { + throw new Error('Input selection applies to address-funded operations only') + } +} + +// Every address a transition may draw on. The quote and the send price the same +// balances, so this is the one filter that decides what either may spend. +export function selectablePlatformInputs( candidates: PlatformSourceCandidate[], + source?: PlatformSpendSource | null, +): PlatformSourceCandidate[] { + // A picked set names its addresses, so one that can no longer cover what it + // was allowed to draw is a refusal rather than one fewer candidate. + if (source?.kind === 'inputs') { + const held = new Map(candidates.map(candidate => [candidate.platformAddress, candidate])) + return source.inputs.map(input => { + const candidate = held.get(input.address) + if (candidate == null || candidate.balanceCredits < input.credits) { + throw new Error('Selected address no longer holds these credits') + } + return candidate + }) + } + + return candidates + .filter(candidate => candidate.balanceCredits >= MIN_INPUT_CREDITS) + .filter(candidate => source == null || candidate.platformAddress === source.address) +} + +// Consensus takes the fee from the remaining balance of the input its strategy +// resolves to, indexed against the inputs in the order they are submitted. +export function selectPlatformInputs( + selectable: PlatformSourceCandidate[], amountCredits: bigint, feeCredits: bigint, - preferredAddress?: string, + source?: PlatformSpendSource | null, + outputCount = 0, ): PlatformInputOutcome { + // Below one input's worth nothing can be funded: whatever carries the amount + // would itself be an input consensus refuses. if (amountCredits < MIN_INPUT_CREDITS) { return refuse(`Minimum amount is ${MIN_INPUT_CREDITS.toString()} credits`) } - if (preferredAddress != null) { - const chosen = candidates.find(candidate => candidate.platformAddress === preferredAddress) - if (chosen == null) { - return refuse('Source address not found in this wallet') - } - if (chosen.balanceCredits < amountCredits + feeCredits) { - return refuse('Source address has insufficient credits for this amount plus fee') - } - return {plan: {inputs: [{candidate: chosen, credits: amountCredits}], feeCredits}, error: null} + // A picked set names the addresses to draw on rather than a walk to run, so + // the allocation is the only thing left to decide. + if (source?.kind === 'inputs') { + return planPickedInputs(selectable, source, amountCredits, feeCredits, outputCount) } - - const sorted = [...candidates] - .filter(candidate => candidate.balanceCredits >= MIN_INPUT_CREDITS) - .sort((a, b) => (a.balanceCredits === b.balanceCredits ? 0 : a.balanceCredits > b.balanceCredits ? -1 : 1)) - + const sorted = [...selectable].sort(byBalanceDesc) const prefix: PlatformSourceCandidate[] = [] let accumulated = 0n @@ -53,12 +101,19 @@ export function selectPlatformInputs( accumulated += candidate.balanceCredits if (accumulated < amountCredits + feeCredits) continue - const plan = planAroundFeeTarget(prefix, accumulated, amountCredits, feeCredits) - if (plan !== null) return {plan, error: null} + const allocatable = prefix.map(entry => ({candidate: entry, cap: entry.balanceCredits})) + const feeTarget = allocatable.reduce((first, entry) => + compareAddresses(entry.candidate, first.candidate) < 0 ? entry : first) + const inputs = allocate(allocatable, feeTarget, amountCredits, feeCredits) + if (inputs !== null) { + return {plan: {inputs, feeCredits, feeStrategy: DEDUCT_FROM_FIRST_INPUT}, error: null} + } } if (accumulated < amountCredits + feeCredits) { - return refuse('Platform addresses do not hold enough credits for this amount plus fee') + return refuse(source == null + ? 'Platform addresses do not hold enough credits for this amount plus fee' + : 'Source address has insufficient credits for this amount plus fee') } return refuse( 'No combination of addresses leaves the fee-paying address enough remaining credits; ' @@ -66,22 +121,62 @@ export function selectPlatformInputs( ) } -function refuse(error: string): PlatformInputOutcome { - return {plan: null, error} +// Not the balance minus a fee: an address worth less than what it adds to the +// fee leaves the set able to send less, so the answer is the best prefix. +export async function maxPlatformCredits( + selectable: PlatformSourceCandidate[], + feeForInputs: PlatformFeeForInputs, + source?: PlatformSpendSource | null, +): Promise { + // No prefix to choose from when every input is named: the price is the one + // that count carries, whether or not a smaller set would have been cheaper. + if (source?.kind === 'inputs') { + const total = source.inputs.reduce((sum, input) => sum + input.credits, 0n) + const charged = source.feeStrategy.some(step => step.kind === 'deductFromInput') + const spendable = charged ? total - await feeForInputs(source.inputs.length) : total + return spendable > 0n ? spendable : 0n + } + + const sorted = [...selectable].sort(byBalanceDesc).slice(0, MAX_ADDRESS_INPUTS) + const prefix: PlatformSourceCandidate[] = [] + let accumulated = 0n + let max = 0n + + for (const candidate of sorted) { + prefix.push(candidate) + accumulated += candidate.balanceCredits + + const feeCredits = await feeForInputs(prefix.length) + const feeTarget = prefix.reduce((first, entry) => compareAddresses(entry, first) < 0 ? entry : first) + // The address consensus charges still has to be an input of its own. + if (feeTarget.balanceCredits - feeCredits < MIN_INPUT_CREDITS) continue + + const spendable = accumulated - feeCredits + if (spendable > max) max = spendable + } + + return max } // The fee scales with the input count, and covering a larger fee can pull in // another input, so re-select until the count the fee was quoted for holds. export async function selectPlatformInputsWithFee( - candidates: PlatformSourceCandidate[], + selectable: PlatformSourceCandidate[], amountCredits: bigint, - feeForInputCount: (inputCount: number) => Promise, - preferredAddress?: string, + feeForInputs: PlatformFeeForInputs, + source?: PlatformSpendSource | null, + outputCount = 0, ): Promise { + // A picked set fixes the count, so its price is settled in one quote. + if (source?.kind === 'inputs') { + const feeCredits = await feeForInputs(Math.max(source.inputs.length, 1)) + return selectPlatformInputs(selectable, amountCredits, feeCredits, source, outputCount) + } + let inputCount = 1 for (;;) { const outcome = selectPlatformInputs( - candidates, amountCredits, await feeForInputCount(inputCount), preferredAddress) + selectable, amountCredits, await feeForInputs(inputCount), source, outputCount) if (outcome.plan === null || outcome.plan.inputs.length <= inputCount) { return outcome } @@ -89,22 +184,87 @@ export async function selectPlatformInputsWithFee( } } -// Loads the fee-paying address as lightly as its peers allow, so what it keeps -// back covers the fee. Null means this prefix cannot, so widen it. -function planAroundFeeTarget( - prefix: PlatformSourceCandidate[], - accumulated: bigint, +// A pick names addresses and a payer; how much each puts in is still allocated, +// because what an input does not draw stays on its address. +function planPickedInputs( + selectable: PlatformSourceCandidate[], + source: Extract, amountCredits: bigint, feeCredits: bigint, -): PlatformInputPlan | null { - const feeTarget = prefix.reduce((first, candidate) => - compareAddresses(candidate, first) < 0 ? candidate : first, - ) + outputCount: number, +): PlatformInputOutcome { + const picked = source.inputs + + if (picked.length === 0) { + return refuse('Pick at least one address to fund this transition') + } + if (picked.length > MAX_ADDRESS_INPUTS) { + return refuse(`A transition takes at most ${MAX_ADDRESS_INPUTS} inputs`) + } + // An input carries its address's next nonce, so two of them would replay it. + if (new Set(picked.map(input => input.address)).size !== picked.length) { + return refuse('An address can fund a transition only once') + } + + const byAddress = new Map(selectable.map(candidate => [candidate.platformAddress, candidate])) + const allocatable: AllocatableInput[] = [] + + for (const input of picked) { + const candidate = byAddress.get(input.address) + if (candidate == null) { + return refuse('Source address not found in this wallet') + } + allocatable.push({candidate, cap: input.credits}) + } - const feeTargetMax = feeTarget.balanceCredits - feeCredits + const charged = source.feeStrategy.filter(step => step.kind === 'deductFromInput') + // A fee taken out of the output leaves every input free to spend its whole cap. + const feeTarget = charged.length === 0 + ? allocatable.reduce((first, entry) => compareAddresses(entry.candidate, first.candidate) < 0 ? entry : first) + : allocatable.find(entry => entry.candidate.platformAddress === charged[0].address) + if (feeTarget == null) { + return refuse('The address paying the fee is not one of the inputs') + } + + const inputs = allocate(allocatable, feeTarget, amountCredits, charged.length === 0 ? 0n : feeCredits) + if (inputs === null) { + return refuse(`${feeTarget.candidate.platformAddress} does not keep back enough credits to pay the fee`) + } + + const {steps, error} = resolveFeeStrategy(source.feeStrategy, inputs, outputCount) + if (steps === null) return refuse(error) + + return {plan: {inputs, feeCredits, feeStrategy: steps}, error: null} +} + +function refuse(error: string): PlatformInputOutcome { + return {plan: null, error} +} + +const byBalanceDesc = (a: PlatformSourceCandidate, b: PlatformSourceCandidate): number => + a.balanceCredits === b.balanceCredits ? 0 : a.balanceCredits > b.balanceCredits ? -1 : 1 + +// How much of one address a transition may draw. What it does not draw stays +// where it is, which is the only change an address-funded transition has. +interface AllocatableInput { + candidate: PlatformSourceCandidate + cap: bigint +} + +// Loads the fee-paying address as lightly as its peers allow, so what it keeps +// back covers the fee. Null means these inputs cannot fund the amount. +function allocate( + allocatable: AllocatableInput[], + feeTarget: AllocatableInput, + amountCredits: bigint, + feeCredits: bigint, +): PlatformInputSelection[] | null { + const keptBack = feeTarget.candidate.balanceCredits - feeCredits + const feeTargetMax = feeTarget.cap < keptBack ? feeTarget.cap : keptBack if (feeTargetMax < MIN_INPUT_CREDITS) return null - const peersTotal = accumulated - feeTarget.balanceCredits + const peersTotal = allocatable.reduce( + (sum, entry) => entry === feeTarget ? sum : sum + entry.cap, 0n) const shortfall = amountCredits - peersTotal const feeTargetMin = shortfall > MIN_INPUT_CREDITS ? shortfall : MIN_INPUT_CREDITS if (feeTargetMin > feeTargetMax) return null @@ -112,22 +272,22 @@ function planAroundFeeTarget( const inputs: PlatformInputSelection[] = [] let remaining = amountCredits - feeTargetMin - for (const candidate of prefix) { - if (candidate === feeTarget || remaining === 0n) continue - const credits = candidate.balanceCredits < remaining ? candidate.balanceCredits : remaining + for (const entry of allocatable) { + if (entry === feeTarget || remaining === 0n) continue + const credits = entry.cap < remaining ? entry.cap : remaining // A share below the protocol minimum cannot be its own input; the fee // target carries it instead. if (credits < MIN_INPUT_CREDITS) continue - inputs.push({candidate, credits}) + inputs.push({candidate: entry.candidate, credits}) remaining -= credits } const feeTargetCredits = feeTargetMin + remaining if (feeTargetCredits > feeTargetMax) return null - inputs.push({candidate: feeTarget, credits: feeTargetCredits}) + inputs.push({candidate: feeTarget.candidate, credits: feeTargetCredits}) inputs.sort((a, b) => compareAddresses(a.candidate, b.candidate)) - return {inputs, feeCredits} + return inputs } function compareAddresses(a: PlatformSourceCandidate, b: PlatformSourceCandidate): number { diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 7b9b646a..6db319c5 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -14,6 +14,19 @@ type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// Mirrors src/main/src/types/PlatformTransfer: one address to draw from, or +// every address it may draw on, how much of each, and which one is charged. +type PlatformPickedInput = { address: string; credits: bigint } +type PlatformFeeStep = + | { kind: 'deductFromInput'; address: string } + | { kind: 'reduceOutput'; index: number } +// Mirrors the Recipient in src/main/platform/types/messages: one transition can +// pay many addresses, each its own amount. +type PlatformRecipient = { address: string; amountCredits: bigint } +type PlatformSpendSource = + | { kind: 'address'; address: string } + | { kind: 'inputs'; inputs: PlatformPickedInput[]; feeStrategy: PlatformFeeStep[] } + export const apiDefinitions = (ipcRenderer) => ({ createWallet: (seedphrase: string, network: Network, password: string) => ipcRenderer.invoke('createWallet', seedphrase, network, password), deleteWallet: (walletId: string) => ipcRenderer.invoke('deleteWallet', walletId), @@ -41,13 +54,13 @@ export const apiDefinitions = (ipcRenderer) => ({ sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => ipcRenderer.invoke('sendTransaction', walletId, toAddress, amountDuffs, password, source), getTxLockStatus: (walletId: string, txid: string) => ipcRenderer.invoke('getTxLockStatus', walletId, txid), estimateFee: (walletId: string, operation: string, params: unknown) => ipcRenderer.invoke('estimateFee', walletId, operation, params), - sendPlatformTransfer: (walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('sendPlatformTransfer', walletId, fromAddress, toAddress, amountCredits, password), - topUpIdentityFromAddresses: (walletId: string, identityId: string, fromAddress: string | null, amountCredits: bigint, password: string) => ipcRenderer.invoke('topUpIdentityFromAddresses', walletId, identityId, fromAddress, amountCredits, password), - withdrawPlatformCredits: (walletId: string, fromAddress: string | null, toCoreAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('withdrawPlatformCredits', walletId, fromAddress, toCoreAddress, amountCredits, password), + sendPlatformTransfer: (walletId: string, source: PlatformSpendSource | null, recipients: PlatformRecipient[], password: string) => ipcRenderer.invoke('sendPlatformTransfer', walletId, source, recipients, password), + topUpIdentityFromAddresses: (walletId: string, identityId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string) => ipcRenderer.invoke('topUpIdentityFromAddresses', walletId, identityId, source, amountCredits, password), + withdrawPlatformCredits: (walletId: string, source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('withdrawPlatformCredits', walletId, source, toCoreAddress, amountCredits, password), sendIdentityCredits: (walletId: string, identityId: string, toAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('sendIdentityCredits', walletId, identityId, toAddress, amountCredits, password), transferIdentityCredits: (walletId: string, fromIdentityId: string, toIdentityId: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('transferIdentityCredits', walletId, fromIdentityId, toIdentityId, amountCredits, password), withdrawIdentityCredits: (walletId: string, identityId: string, toCoreAddress: string, amountCredits: bigint, password: string) => ipcRenderer.invoke('withdrawIdentityCredits', walletId, identityId, toCoreAddress, amountCredits, password), - createIdentityFromAddresses: (walletId: string, fromAddress: string | null, amountCredits: bigint, password: string) => ipcRenderer.invoke('createIdentityFromAddresses', walletId, fromAddress, amountCredits, password), + createIdentityFromAddresses: (walletId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string) => ipcRenderer.invoke('createIdentityFromAddresses', walletId, source, amountCredits, password), startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string, source?: CoreSpendSource) => ipcRenderer.invoke('startAssetLockFunding', walletId, toPlatformAddress, amountDuffs, password, kind, source), getAssetLockFundingState: (walletId: string) => ipcRenderer.invoke('getAssetLockFundingState', walletId), resumeAssetLockFunding: (walletId: string, password: string) => ipcRenderer.invoke('resumeAssetLockFunding', walletId, password), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 02cefee1..c3484587 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -16,6 +16,19 @@ type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// Mirrors src/main/src/types/PlatformTransfer: one address to draw from, or +// every address it may draw on, how much of each, and which one is charged. +type PlatformPickedInput = { address: string; credits: bigint } +type PlatformFeeStep = + | { kind: 'deductFromInput'; address: string } + | { kind: 'reduceOutput'; index: number } +// Mirrors the Recipient in src/main/platform/types/messages: one transition can +// pay many addresses, each its own amount. +type PlatformRecipient = { address: string; amountCredits: bigint } +type PlatformSpendSource = + | { kind: 'address'; address: string } + | { kind: 'inputs'; inputs: PlatformPickedInput[]; feeStrategy: PlatformFeeStep[] } + // Every coin a send can draw on: what getUtxos lists and what an outpoints // source picks from. interface SelectableUtxoDTO { @@ -99,13 +112,13 @@ declare global { sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => Promise getTxLockStatus: (walletId: string, txid: string) => Promise estimateFee: (walletId: string, operation: string, params: unknown) => Promise<{ feeCredits: bigint | null; feeDuffs: bigint | null; maxDuffs: bigint | null; maxPerTx: bigint | null; noteLimit: number | null }> - sendPlatformTransfer: (walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string) => Promise - topUpIdentityFromAddresses: (walletId: string, identityId: string, fromAddress: string | null, amountCredits: bigint, password: string) => Promise - withdrawPlatformCredits: (walletId: string, fromAddress: string | null, toCoreAddress: string, amountCredits: bigint, password: string) => Promise + sendPlatformTransfer: (walletId: string, source: PlatformSpendSource | null, recipients: PlatformRecipient[], password: string) => Promise + topUpIdentityFromAddresses: (walletId: string, identityId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string) => Promise + withdrawPlatformCredits: (walletId: string, source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string) => Promise sendIdentityCredits: (walletId: string, identityId: string, toAddress: string, amountCredits: bigint, password: string) => Promise transferIdentityCredits: (walletId: string, fromIdentityId: string, toIdentityId: string, amountCredits: bigint, password: string) => Promise withdrawIdentityCredits: (walletId: string, identityId: string, toCoreAddress: string, amountCredits: bigint, password: string) => Promise - createIdentityFromAddresses: (walletId: string, fromAddress: string | null, amountCredits: bigint, password: string) => Promise + createIdentityFromAddresses: (walletId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string) => Promise startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string, source?: CoreSpendSource) => Promise getAssetLockFundingState: (walletId: string) => Promise resumeAssetLockFunding: (walletId: string, password: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 01a3d17e..40576dd1 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,6 +1,6 @@ import { WalletTxDto } from '@renderer/types/WalletTransaction' import { TransferOperation } from '../enums/TransferOperation' -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformRecipient, PlatformSendResult, PlatformSpendSource, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' export class API { private static get api() { @@ -195,16 +195,16 @@ export class API { return this.api.refreshShieldedSpentNotes(walletId) as Promise } - static async sendPlatformTransfer(walletId: string, fromAddress: string, toAddress: string, amountCredits: bigint, password: string): Promise { - return this.api.sendPlatformTransfer(walletId, fromAddress, toAddress, amountCredits, password) as Promise + static async sendPlatformTransfer(walletId: string, source: PlatformSpendSource | null, recipients: PlatformRecipient[], password: string): Promise { + return this.api.sendPlatformTransfer(walletId, source, recipients, password) as Promise } - static async topUpIdentityFromAddresses(walletId: string, identityId: string, fromAddress: string | null, amountCredits: bigint, password: string): Promise { - return this.api.topUpIdentityFromAddresses(walletId, identityId, fromAddress, amountCredits, password) as Promise + static async topUpIdentityFromAddresses(walletId: string, identityId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string): Promise { + return this.api.topUpIdentityFromAddresses(walletId, identityId, source, amountCredits, password) as Promise } - static async withdrawPlatformCredits(walletId: string, fromAddress: string | null, toCoreAddress: string, amountCredits: bigint, password: string): Promise { - return this.api.withdrawPlatformCredits(walletId, fromAddress, toCoreAddress, amountCredits, password) as Promise + static async withdrawPlatformCredits(walletId: string, source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string): Promise { + return this.api.withdrawPlatformCredits(walletId, source, toCoreAddress, amountCredits, password) as Promise } static async sendIdentityCredits(walletId: string, identityId: string, toAddress: string, amountCredits: bigint, password: string): Promise { @@ -219,8 +219,8 @@ export class API { return this.api.withdrawIdentityCredits(walletId, identityId, toCoreAddress, amountCredits, password) as Promise } - static async createIdentityFromAddresses(walletId: string, fromAddress: string | null, amountCredits: bigint, password: string): Promise { - return this.api.createIdentityFromAddresses(walletId, fromAddress, amountCredits, password) as Promise + static async createIdentityFromAddresses(walletId: string, source: PlatformSpendSource | null, amountCredits: bigint, password: string): Promise { + return this.api.createIdentityFromAddresses(walletId, source, amountCredits, password) as Promise } static async startAssetLockFunding(walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind: AssetLockFundingKind = AssetLockFundingKind.Address, source?: CoreSpendSource): Promise { diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index a706ad0e..3d8d1ab1 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -64,6 +64,31 @@ export type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// One transition can pay many addresses, each its own amount. +export interface PlatformRecipient { + address: string + amountCredits: bigint +} + +// The most of one Platform address a transition may draw. Credits are divisible, +// so what it does not draw stays where it is. +export interface PlatformPickedInput { + address: string + credits: bigint +} + +// Which input pays the fee, named by address: the index consensus reads is a +// position in the byte-sorted inputs, which only the main process can compute. +export type PlatformFeeStep = + | { kind: 'deductFromInput'; address: string } + | { kind: 'reduceOutput'; index: number } + +// One address to draw from, or every address it may draw on, how much of each, +// and which one is charged. +export type PlatformSpendSource = + | { kind: 'address'; address: string } + | { kind: 'inputs'; inputs: PlatformPickedInput[]; feeStrategy: PlatformFeeStep[] } + // getUtxos — every coin a send can draw on, which is also everything an // outpoints source may pick from. export interface SelectableUtxo { @@ -84,10 +109,10 @@ export interface FeeParams { // L1 quotes only: the fee scales with the inputs the amount takes. amountDuffs?: bigint | null // L1 quotes only: narrows the funding to one Core address, or to coins the - // user picked. Kept apart from sourceAddress, which names a platform address. + // user picked. Kept apart from platformSource, which names platform addresses. coreSource?: CoreSpendSource | null // Optional because most operations read none of them. - sourceAddress?: string | null + platformSource?: PlatformSpendSource | null identityId?: string | null // Narrows a pool spend to one shielded address's notes, or names the notes. shieldedSource?: ShieldedSpendSource | null diff --git a/src/renderer/src/components/pages/identities/Registration.tsx b/src/renderer/src/components/pages/identities/Registration.tsx index 1851003e..8af00768 100644 --- a/src/renderer/src/components/pages/identities/Registration.tsx +++ b/src/renderer/src/components/pages/identities/Registration.tsx @@ -15,7 +15,7 @@ import P2pSyncAlert from '@renderer/components/ui/P2pSyncAlert' import ShieldedNotesAlert from '@renderer/components/ui/ShieldedNotesAlert' import Spinner from '@renderer/components/ui/Spinner' import { API } from '@renderer/api' -import { AssetLockFundingState, ShieldedSpendState } from '@renderer/api/types' +import { AssetLockFundingState, PlatformSpendSource, ShieldedSpendState } from '@renderer/api/types' import { IDENTITY_REGISTRATION_DEFAULT_AMOUNT } from '@renderer/constants' import { useAuth } from '@renderer/contexts/AuthContext' import { useConnectionModeContext } from '@renderer/contexts/ConnectionModeContext' @@ -122,6 +122,13 @@ export default function IdentityRegistration(): React.JSX.Element { const shieldedBalance = shieldedSync.phase === ShieldedSyncPhase.Done && shieldedSync.balance !== null ? BigInt(shieldedSync.balance) : null + // Only the transitions platform addresses fund read this; an L1 registration + // is funded by coins and names none. + const platformSource: PlatformSpendSource | null = + selectedSource != null && operation === TransferOperation.IdentityCreate + ? { kind: 'address', address: selectedSource.platformAddress } + : null + const availableCredits = fromKind === SourceKind.PlatformAddress ? BigInt(selectedSource?.balanceCredits ?? 0n) : fromKind === SourceKind.Shielded @@ -133,7 +140,7 @@ export default function IdentityRegistration(): React.JSX.Element { recipient: '', amountCredits, amountDuffs: fromKind === SourceKind.Core ? amountDuffs : null, - sourceAddress: selectedSource?.platformAddress ?? null, + platformSource, identityId: null, shieldedSource: null, }) @@ -248,7 +255,7 @@ export default function IdentityRegistration(): React.JSX.Element { const runPlatformRegistration = (password: string) => { if (!walletId) return Promise.reject(new Error('No wallet selected')) - return API.createIdentityFromAddresses(walletId, selectedSource?.platformAddress ?? null, amountCredits, password) + return API.createIdentityFromAddresses(walletId, platformSource, amountCredits, password) .then(result => ({ stHash: result.stHash, amountCredits: result.amountCredits, diff --git a/src/renderer/src/components/pages/transfer/EndpointPicker.tsx b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx index 57559810..92af4e8b 100644 --- a/src/renderer/src/components/pages/transfer/EndpointPicker.tsx +++ b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx @@ -107,6 +107,8 @@ interface SourcePickerProps { platformAddresses: PlatformAddressDto[] selectedPlatformAddress: PlatformAddressDto | undefined onPlatformAddressChange: (address: string) => void + // Off while the inputs are being picked, which lists the same addresses. + showPlatformAddress?: boolean identities: IdentityApiDto[] selectedIdentity: IdentityApiDto | undefined onIdentityChange: (identifier: string) => void @@ -120,6 +122,7 @@ export function SourcePicker({ platformAddresses, selectedPlatformAddress, onPlatformAddressChange, + showPlatformAddress = true, identities, selectedIdentity, onIdentityChange, @@ -128,7 +131,7 @@ export function SourcePicker({
{label} onKindChange(k as SourceKind)} /> - {kind === SourceKind.PlatformAddress && ( + {kind === SourceKind.PlatformAddress && showPlatformAddress && ( void + onClear: () => void + feeAddress: string | null + onFeeAddressChange: (platformAddress: string) => void + feeCredits: bigint | null + maxInputs: number +} + +export default function PlatformInputPicker({ + addresses, picked, onToggle, onClear, feeAddress, onFeeAddressChange, feeCredits, maxInputs, +}: PlatformInputPickerProps): React.JSX.Element { + const chosen = new Set(picked) + const full = picked.length >= maxInputs + const total = addresses + .filter(entry => chosen.has(entry.platformAddress)) + .reduce((sum, entry) => sum + BigInt(entry.balanceCredits), 0n) + + return ( +
+
+ + Picked {picked.length}/{maxInputs} + +
+ + + + {picked.length > 0 && ( + + )} +
+
+ +
+ {addresses.length === 0 && ( + + No funded Platform addresses + + )} + {addresses.map(entry => { + const isPicked = chosen.has(entry.platformAddress) + const paysFee = isPicked && entry.platformAddress === feeAddress + const keptBack = paysFee && feeCredits !== null ? feeCredits : 0n + const short = paysFee && feeCredits !== null && BigInt(entry.balanceCredits) <= feeCredits + + return ( +
+ (isPicked || !full) && onToggle(entry.platformAddress, next)} + label={ +
+ +
+ + {entry.platformAddress} + + + + {paysFee && ' after the fee'} + +
+
+ } + /> + + {isPicked && ( + + )} +
+ ) + })} +
+ + + The amount is drawn from the addresses you pick, largest share first, and + whatever is not drawn stays where it is. The one paying keeps the fee back + out of its own balance, and a transition takes at most {maxInputs} of them. + +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/PlatformRecipientsTest.tsx b/src/renderer/src/components/pages/transfer/PlatformRecipientsTest.tsx new file mode 100644 index 00000000..10945903 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/PlatformRecipientsTest.tsx @@ -0,0 +1,65 @@ +import { Text } from "@renderer/components/dash-ui-kit-enxtended"; + +// TEST ONLY, to be reverted. It names addresses alone: the amount stays the one +// typed on the amount step, so the rest of the send flow is untouched. +interface PlatformRecipientsTestProps { + addresses: string[] + onChange: (addresses: string[]) => void + maxRecipients: number +} + +export default function PlatformRecipientsTest({addresses, onChange, maxRecipients}: PlatformRecipientsTestProps): React.JSX.Element { + return ( +
+
+ + Extra recipients {addresses.length}/{maxRecipients} (test) + +
+ + {addresses.length > 0 && ( + + )} +
+
+ + {addresses.map((address, index) => ( +
+ onChange(addresses.map((entry, i) => (i === index ? e.target.value : entry)))} + placeholder={"Platform address"} + className={"flex-1 min-w-0 bg-transparent outline-none dash-text-default placeholder:opacity-30 text-[.8125rem] font-mono"} + /> + +
+ ))} + + {addresses.length > 0 && ( + + The amount you type is split evenly between the recipient above and + these, with any remainder going to the first. + + )} +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 5b39942c..260b5fdd 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -6,6 +6,9 @@ import P2pSyncAlert from "@renderer/components/ui/P2pSyncAlert"; import ShieldedNotesAlert from "@renderer/components/ui/ShieldedNotesAlert"; import CreditsAmount from "@renderer/components/ui/CreditsAmount"; import Checkbox from "@renderer/components/ui/Checkbox"; +import PlatformInputPicker from "./PlatformInputPicker"; +import PlatformRecipientsTest from "./PlatformRecipientsTest"; +import { PLATFORM_INPUT_LIMIT, PLATFORM_RECIPIENT_LIMIT } from "@renderer/constants/platform"; import ProverPill from "@renderer/components/pages/shielded/ProverPill"; import Spinner from "@renderer/components/ui/Spinner"; import { useAuth } from "@renderer/contexts/AuthContext"; @@ -48,7 +51,7 @@ import { ShieldedSpendPhase } from "@renderer/enums/ShieldedSpendPhase"; import { AssetLockFundingPhase } from "@renderer/enums/AssetLockFundingPhase"; import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; -import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; +import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, PlatformSpendSource, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; import type { SendDraft } from "@renderer/types/SendDraft"; import type { SpecificSourcePreferences } from "@renderer/types/SpecificSource"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; @@ -101,6 +104,9 @@ function WalletTransferHub(): React.JSX.Element { ...current, specificSourcePreferences: update(current.specificSourcePreferences), })) + const [testRecipients, setTestRecipients] = useState([]) + const [pickedPlatformInputs, setPickedPlatformInputs] = useState([]) + const [platformFeeAddress, setPlatformFeeAddress] = useState(null) const [confirmOpen, setConfirmOpen] = useState(false) const [notesUnlockOpen, setNotesUnlockOpen] = useState(false) const [wizardKey, setWizardKey] = useState(0) @@ -223,24 +229,82 @@ function WalletTransferHub(): React.JSX.Element { [shieldedSpecificNotes], ) + const platformPicking = specificSourceKind === SourceKind.PlatformAddress && useSpecificSource + const pickedPlatformAddresses = useMemo( + () => fundedAddresses.filter(a => pickedPlatformInputs.includes(a.platformAddress)), + [fundedAddresses, pickedPlatformInputs], + ) + // Consensus charges one input, so a pick that lost its payer falls back to the + // address most likely to keep the fee back. + const platformFeePayer = pickedPlatformAddresses.some(a => a.platformAddress === platformFeeAddress) + ? platformFeeAddress + : pickedPlatformAddresses.reduce( + (best, a) => (best == null || BigInt(a.balanceCredits) > BigInt(best.balanceCredits) ? a : best), + undefined, + )?.platformAddress ?? null + + // Consensus refuses an output address that is also an input, so a transfer + // back into what funds it is caught before the amount step. + const fundingAddresses = platformPicking + ? pickedPlatformInputs + : selectedSource ? [selectedSource.platformAddress] : [] + + // A pick names the addresses to draw on and the one that pays; how much each + // puts in is the backend's to allocate. + const platformSource: PlatformSpendSource | null = useMemo( + () => { + if (specificSourceKind !== SourceKind.PlatformAddress) return null + if (platformPicking) { + if (pickedPlatformAddresses.length === 0 || platformFeePayer == null) return null + return { + kind: 'inputs', + inputs: pickedPlatformAddresses.map(a => ({ address: a.platformAddress, credits: BigInt(a.balanceCredits) })), + feeStrategy: [{ kind: 'deductFromInput', address: platformFeePayer }], + } + } + return selectedSource ? { kind: 'address', address: selectedSource.platformAddress } : null + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [specificSourceKind, platformPicking, pickedPlatformAddresses, platformFeePayer, selectedSource?.platformAddress], + ) + const balanceDuffs = coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount const shieldedBalance = shieldedSync.phase === ShieldedSyncPhase.Done && shieldedSync.balance !== null ? BigInt(shieldedSync.balance) : null const availableCredits: bigint | null = - fromKind === SourceKind.PlatformAddress ? (selectedSource ? BigInt(selectedSource.balanceCredits) : 0n) + fromKind === SourceKind.PlatformAddress ? (platformPicking && pickedPlatformAddresses.length > 0 + ? pickedPlatformAddresses.reduce((sum, a) => sum + BigInt(a.balanceCredits), 0n) + : selectedSource ? BigInt(selectedSource.balanceCredits) : 0n) : fromKind === SourceKind.Identity ? (selectedIdentity ? BigInt(String(selectedIdentity.balance.amount)) : 0n) : fromKind === SourceKind.Shielded ? (shieldedSpecificNotes != null ? shieldedSpecificNotes.reduce((sum, n) => sum + BigInt(n.amount), 0n) : shieldedBalance) : null const isCoreOperation = fromKind === SourceKind.Core const amountDuffs = useMemo(() => dashToDuffs(amount), [amount]) - const amountCredits = isCoreOperation ? 0n : duffsToCredits(amountDuffs) const minCredits = info?.minCredits ?? 0n - const trimmedTo = toValue.trim() - const destinationValid = - toKind === DestinationKind.CoreAddress ? isValidDashAddress(trimmedTo, network ?? undefined) + const amountCredits = isCoreOperation ? 0n : duffsToCredits(amountDuffs) + + // TEST ONLY. The extra addresses join the one typed above, and the amount from + // the amount step is split between them, so nothing else in the flow changes. + const manyRecipients = operation === TransferOperation.AddressFundsTransfer && testRecipients.length > 0 + const recipientList = useMemo( + () => { + const addresses = [trimmedTo, ...testRecipients.map(entry => entry.trim())].filter(entry => entry.length > 0) + const share = addresses.length === 0 ? 0n : amountCredits / BigInt(addresses.length) + return addresses.map((address, index) => ({ + address, + amountCredits: index === 0 ? amountCredits - share * BigInt(addresses.length - 1) : share, + })) + }, + [trimmedTo, testRecipients, amountCredits], + ) + + const destinationValid = manyRecipients + ? recipientList.length === testRecipients.length + 1 + && recipientList.every(entry => isValidPlatformAddress(entry.address, network ?? undefined)) + : toKind === DestinationKind.CoreAddress ? isValidDashAddress(trimmedTo, network ?? undefined) : toKind === DestinationKind.PlatformAddress ? isValidPlatformAddress(trimmedTo, network ?? undefined) : toKind === DestinationKind.Identity ? isLikelyIdentityId(trimmedTo) : toKind === DestinationKind.NewIdentity ? true @@ -248,11 +312,11 @@ function WalletTransferHub(): React.JSX.Element { const { feeCredits, feeDuffs, maxDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr } = useOperationFee(walletId, operation, { destinationValid, - recipient: trimmedTo, + recipient: manyRecipients ? recipientList.map(entry => entry.address) : trimmedTo, amountCredits, amountDuffs: isCoreOperation ? amountDuffs : null, coreSource: coreSpendSource ?? null, - sourceAddress: selectedSource?.platformAddress ?? null, + platformSource, identityId: selectedIdentity?.identifier ?? null, shieldedSource: shieldedSpendSource ?? null, }) @@ -296,7 +360,8 @@ function WalletTransferHub(): React.JSX.Element { : true const selfSend = - (operation === TransferOperation.AddressFundsTransfer && destinationValid && selectedSource != null && trimmedTo === selectedSource.platformAddress) + (operation === TransferOperation.AddressFundsTransfer && destinationValid + && (manyRecipients ? recipientList : [{address: trimmedTo}]).some(entry => fundingAddresses.includes(entry.address))) || (operation === TransferOperation.IdentityToIdentity && destinationValid && selectedIdentity != null && trimmedTo === selectedIdentity.identifier) const destinationError = toKind === DestinationKind.NewIdentity || trimmedTo.length === 0 @@ -371,6 +436,9 @@ function WalletTransferHub(): React.JSX.Element { const fieldError = amountError ?? feeErr const resetForm = (): void => { + setPickedPlatformInputs([]) + setPlatformFeeAddress(null) + setTestRecipients([]) const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false } draftRef.current = resetDraft setDraftState(resetDraft) @@ -390,6 +458,7 @@ function WalletTransferHub(): React.JSX.Element { platformAddresses={fundedAddresses} selectedPlatformAddress={selectedSource} onPlatformAddressChange={setFromAddress} + showPlatformAddress={!platformPicking} identities={identities} selectedIdentity={selectedIdentity} onIdentityChange={setFromIdentity} @@ -401,7 +470,13 @@ function WalletTransferHub(): React.JSX.Element { checked={useSpecificSource} onChange={enabled => setSpecificSourcePreferences(current => updateSpecificSourceEnabled(current, enabled))} - label={Send from a specific address} + label={ + + {specificSourceKind === SourceKind.PlatformAddress + ? 'Choose which addresses fund this' + : 'Send from a specific address'} + + } /> {useSpecificSource && specificSourceKind === SourceKind.Core && ( )} + {operation === TransferOperation.AddressFundsTransfer && ( + + )} + {platformPicking && ( + setPickedPlatformInputs(current => + checked ? [...current, address] : current.filter(entry => entry !== address))} + onClear={() => setPickedPlatformInputs([])} + feeAddress={platformFeePayer} + onFeeAddressChange={setPlatformFeeAddress} + feeCredits={feeCredits} + maxInputs={PLATFORM_INPUT_LIMIT} + /> + )} {useSpecificSource && shieldedSpendOperation && ( <> { if (!walletId) return Promise.reject(new Error('No wallet selected')) - const sourceAddress = selectedSource?.platformAddress ?? null if (operation === TransferOperation.AddressFundsTransfer) { - return API.sendPlatformTransfer(walletId, sourceAddress ?? '', trimmedTo, amountCredits, password) + return API.sendPlatformTransfer( + walletId, + platformSource, + manyRecipients ? recipientList : [{ address: trimmedTo, amountCredits }], + password, + ) } if (operation === TransferOperation.IdentityTopUp) { - return API.topUpIdentityFromAddresses(walletId, trimmedTo, sourceAddress, amountCredits, password) + return API.topUpIdentityFromAddresses(walletId, trimmedTo, platformSource, amountCredits, password) } if (operation === TransferOperation.AddressWithdrawal) { - return API.withdrawPlatformCredits(walletId, sourceAddress, trimmedTo, amountCredits, password) + return API.withdrawPlatformCredits(walletId, platformSource, trimmedTo, amountCredits, password) } if (operation === TransferOperation.IdentityToIdentity) { return API.transferIdentityCredits(walletId, selectedIdentity?.identifier ?? '', trimmedTo, amountCredits, password) @@ -707,7 +806,7 @@ function WalletTransferHub(): React.JSX.Element { return API.withdrawIdentityCredits(walletId, selectedIdentity?.identifier ?? '', trimmedTo, amountCredits, password) } if (operation === TransferOperation.IdentityCreate) { - return API.createIdentityFromAddresses(walletId, sourceAddress, amountCredits, password) + return API.createIdentityFromAddresses(walletId, platformSource, amountCredits, password) .then(result => ({ stHash: result.stHash, amountCredits: result.amountCredits, diff --git a/src/renderer/src/constants/platform.ts b/src/renderer/src/constants/platform.ts new file mode 100644 index 00000000..d51a8467 --- /dev/null +++ b/src/renderer/src/constants/platform.ts @@ -0,0 +1,5 @@ +// Consensus caps the inputs one address-funded transition may carry. +export const PLATFORM_INPUT_LIMIT = 16 + +// Consensus caps the address outputs one transition may pay. +export const PLATFORM_RECIPIENT_LIMIT = 128 diff --git a/src/renderer/src/hooks/useOperationFee.ts b/src/renderer/src/hooks/useOperationFee.ts index 0117a8fe..aaa34f4e 100644 --- a/src/renderer/src/hooks/useOperationFee.ts +++ b/src/renderer/src/hooks/useOperationFee.ts @@ -12,9 +12,15 @@ export function useOperationFee( operation: TransferOperation | null, params: OperationFeeParams, ): OperationFee & { loading: boolean; err: string | null } { - const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, shieldedSource } = params + const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, platformSource, identityId, shieldedSource } = params const noteKey = shieldedSource == null ? '' : `${shieldedSource.kind}:${shieldedSource.noteIndexes.join(',')}` + const platformSourceKey = platformSource == null + ? '' + : platformSource.kind === 'address' + ? platformSource.address + : platformSource.inputs.map(input => `${input.address}:${input.credits}`).join(',') + + `|${platformSource.feeStrategy.map(step => step.kind === 'deductFromInput' ? step.address : step.index).join(',')}` const coreSourceKey = coreSource == null ? '' : coreSource.kind === 'address' @@ -24,14 +30,14 @@ export function useOperationFee( const pending = useMemo( () => { if (walletId === null || operation === null || !destinationValid) return null - const feeParams = { amountCredits, amountDuffs, recipient, coreSource, sourceAddress, identityId, shieldedSource } - return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${coreSourceKey}:${sourceAddress}:${identityId}:${noteKey}` } + const feeParams = { amountCredits, amountDuffs, recipient, coreSource, platformSource, identityId, shieldedSource } + return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${coreSourceKey}:${platformSourceKey}:${identityId}:${noteKey}` } }, // shieldedSource and coreSource are keyed by their string forms: a fresh array // or object holding the same pick is the same quote, and re-running on // identity would re-ask on every render. // eslint-disable-next-line react-hooks/exhaustive-deps - [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, coreSourceKey, sourceAddress, identityId, noteKey], + [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, coreSourceKey, platformSourceKey, identityId, noteKey], ) const [settled, setSettled] = useState(null) diff --git a/src/renderer/src/types/SpecificSource.ts b/src/renderer/src/types/SpecificSource.ts index 38286556..807253a8 100644 --- a/src/renderer/src/types/SpecificSource.ts +++ b/src/renderer/src/types/SpecificSource.ts @@ -1,6 +1,6 @@ import { SourceKind } from '../enums/SourceKind' -export type SpecificSourceKind = SourceKind.Core | SourceKind.Shielded +export type SpecificSourceKind = SourceKind.Core | SourceKind.PlatformAddress | SourceKind.Shielded export interface SpecificSourcePreferences { enabled: boolean diff --git a/src/renderer/src/utils/specificSource.ts b/src/renderer/src/utils/specificSource.ts index 0c18da66..997bf786 100644 --- a/src/renderer/src/utils/specificSource.ts +++ b/src/renderer/src/utils/specificSource.ts @@ -10,6 +10,7 @@ export function initialSpecificSourcePreferences(): SpecificSourcePreferences { enabled: false, addresses: { [SourceKind.Core]: null, + [SourceKind.PlatformAddress]: null, [SourceKind.Shielded]: null, }, } @@ -27,6 +28,16 @@ export function specificSourceKindForOperation(operation: TransferOperation | nu ) { return SourceKind.Core } + // The three transitions whose fee scales with the inputs they take, which are + // the only ones a pick can name. + if ( + operation === TransferOperation.AddressFundsTransfer + || operation === TransferOperation.AddressWithdrawal + || operation === TransferOperation.IdentityCreate + || operation === TransferOperation.IdentityTopUp + ) { + return SourceKind.PlatformAddress + } if ( operation === TransferOperation.ShieldedTransfer || operation === TransferOperation.Unshield diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index fe03f81f..78afbab1 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -78,7 +78,7 @@ function service(candidates: PlatformSourceCandidate[] = [], utxos: UTXO[] = []) } function params(overrides: Partial = {}): FeeParams { - return {amountCredits: 1_000_000n, recipient: 'tdash1qrecipient', sourceAddress: null, identityId: IDENTITY, shieldedSource: null, ...overrides} + return {amountCredits: 1_000_000n, recipient: 'tdash1qrecipient', platformSource: null, identityId: IDENTITY, shieldedSource: null, ...overrides} } function feeCalls(request: ReturnType): Array<{operation: string; params: FeeQuoteParams}> { @@ -256,17 +256,15 @@ describe('estimateFee', () => { expect(fee.feeDuffs).toBe(CORE_FEE(2)) }) - // sourceAddress names a platform address on these operations, and reading it - // as an L1 filter matched no coin at all, so Max offered nothing. - it('funds an asset lock from the whole wallet while a platform source is named', async () => { + // An asset lock is funded by L1 coins, so a platform address names nothing it + // could spend — refused rather than ignored. + it('refuses a platform input pick on an asset lock', async () => { const {service: svc} = service([], [utxo(ONE_DASH, 1)]) - const fee = await svc.estimateFee(WALLET, 'identityRegister', params({ + await expect(svc.estimateFee(WALLET, 'identityRegister', params({ amountDuffs: 1_000n, - sourceAddress: 'tdash1qsourceplatformaddress', - })) - - expect(fee.maxDuffs).toBe(ONE_DASH - ASSET_LOCK_FEE(1)) + platformSource: {kind: 'address', address: 'tdash1qsourceplatformaddress'}, + }))).rejects.toThrow('address-funded operations only') }) // An asset lock is funded by L1 coins like any other send, and the link it diff --git a/tests/unit/platformTransfer.test.ts b/tests/unit/platformTransfer.test.ts index 80a55b0c..f116df5d 100644 --- a/tests/unit/platformTransfer.test.ts +++ b/tests/unit/platformTransfer.test.ts @@ -1,7 +1,29 @@ import { describe, it, expect } from 'vitest' -import {selectPlatformInputs, selectPlatformSource, toAddressInput} from '../../src/main/src/utils/platformTransfer' -import {PlatformInputPlan, PlatformSourceCandidate} from '../../src/main/src/types/PlatformTransfer' -import {MAX_ADDRESS_INPUTS, MIN_INPUT_CREDITS, MIN_OUTPUT_CREDITS} from '../../src/main/src/constants/credits' +import { + maxPlatformCredits, + requireAutomaticInputs, + selectPlatformInputs, + selectPlatformSource, + requireRecipients, + selectablePlatformInputs, + toAddressInput, +} from '../../src/main/src/utils/platformTransfer' +import {Recipient} from '../../src/main/platform/types/messages' +import { + PlatformFeeStep, + PlatformInputOutcome, + PlatformInputPlan, + PlatformPickedInput, + PlatformSourceCandidate, + PlatformSpendSource, +} from '../../src/main/src/types/PlatformTransfer' +import { + MAX_ADDRESS_INPUTS, + MAX_FEE_STRATEGY_STEPS, + MAX_RECIPIENTS, + MIN_INPUT_CREDITS, + MIN_OUTPUT_CREDITS, +} from '../../src/main/src/constants/credits' // hashByte drives consensus ordering; platformAddress is only a label, so the // two can disagree exactly as bech32m and address bytes do on chain. @@ -28,6 +50,27 @@ const REQUIRED = AMOUNT + FEE const INPUT_FEE = 1_000_000n +const from = (address: string): PlatformSpendSource => ({kind: 'address', address}) + +// The pair every caller runs: the one filter, then the plan over what survived. +function inputsFor( + candidates: PlatformSourceCandidate[], + amountCredits: bigint, + feeCredits: bigint, + source?: PlatformSpendSource, + outputCount = 0, +): PlatformInputOutcome { + return selectPlatformInputs( + selectablePlatformInputs(candidates, source), amountCredits, feeCredits, source, outputCount) +} + +const pick = ( + inputs: PlatformPickedInput[], + feeStrategy: PlatformFeeStep[] = [{kind: 'deductFromInput', address: inputs[0]?.address ?? ''}], +): PlatformSpendSource => ({kind: 'inputs', inputs, feeStrategy}) + +const input = (address: string, credits: bigint): PlatformPickedInput => ({address, credits}) + function consumed(plan: PlatformInputPlan | null, platformAddress: string): bigint { return (plan?.inputs ?? []) .filter(input => input.candidate.platformAddress === platformAddress) @@ -85,7 +128,7 @@ describe('toAddressInput', () => { describe('selectPlatformInputs', () => { it('uses a single input when the largest balance covers amount + fee', () => { - const {plan} = selectPlatformInputs([candidate('a', 10_000_000n), candidate('b', 1_000_000n)], 5_000_000n, INPUT_FEE) + const {plan} = inputsFor([candidate('a', 10_000_000n), candidate('b', 1_000_000n)], 5_000_000n, INPUT_FEE) expect(plan!.inputs).toHaveLength(1) expect(plan!.inputs[0].candidate.platformAddress).toBe('a') expect(plan!.inputs[0].credits).toBe(5_000_000n) @@ -93,14 +136,14 @@ describe('selectPlatformInputs', () => { }) it('splits across inputs largest-first, charging the fee to input 0', () => { - const {plan} = selectPlatformInputs([candidate('b', 3_000_000n), candidate('a', 5_000_000n)], 7_000_000n, INPUT_FEE) + const {plan} = inputsFor([candidate('b', 3_000_000n), candidate('a', 5_000_000n)], 7_000_000n, INPUT_FEE) expect(plan!.inputs.map(input => input.candidate.platformAddress)).toEqual(['a', 'b']) expect(plan!.inputs[0].credits).toBe(4_000_000n) expect(plan!.inputs[1].credits).toBe(3_000_000n) }) it('keeps every input at or above the per-input minimum', () => { - const {plan} = selectPlatformInputs([candidate('a', 5_000_000n), candidate('b', 200_000n)], 4_050_000n, INPUT_FEE) + const {plan} = inputsFor([candidate('a', 5_000_000n), candidate('b', 200_000n)], 4_050_000n, INPUT_FEE) expect(plan!.inputs[0].credits).toBe(3_850_000n) expect(plan!.inputs[1].credits).toBe(200_000n) for (const input of plan!.inputs) { @@ -110,19 +153,19 @@ describe('selectPlatformInputs', () => { it('skips candidates whose usable balance is below the per-input minimum', () => { const candidates = [candidate('a', 1_000_000n), candidate('dust', 50_000n)] - expect(selectPlatformInputs(candidates, 1_500_000n, 0n).error).toMatch(/enough credits/) + expect(inputsFor(candidates, 1_500_000n, 0n).error).toMatch(/enough credits/) }) it('stops at the maximum input count', () => { const candidates = Array.from({length: MAX_ADDRESS_INPUTS + 1}, (_, i) => candidate(`a${i}`, MIN_INPUT_CREDITS, 0, i)) const amount = MIN_INPUT_CREDITS * BigInt(MAX_ADDRESS_INPUTS + 1) - expect(selectPlatformInputs(candidates, amount, 0n).error).toMatch(/enough credits/) + expect(inputsFor(candidates, amount, 0n).error).toMatch(/enough credits/) }) it('selects exactly the maximum input count when that suffices', () => { const candidates = Array.from({length: MAX_ADDRESS_INPUTS}, (_, i) => candidate(`a${i}`, MIN_INPUT_CREDITS, 0, i)) const amount = MIN_INPUT_CREDITS * BigInt(MAX_ADDRESS_INPUTS) - const {plan} = selectPlatformInputs(candidates, amount, 0n) + const {plan} = inputsFor(candidates, amount, 0n) expect(plan!.inputs).toHaveLength(MAX_ADDRESS_INPUTS) }) @@ -133,7 +176,7 @@ describe('selectPlatformInputs', () => { const feePayer = candidate('small-but-first', 3_000_000n, 0, 0x01) const peer = candidate('large-but-second', 9_000_000n, 0, 0x02) - const {plan} = selectPlatformInputs([peer, feePayer], 11_000_000n, INPUT_FEE) + const {plan} = inputsFor([peer, feePayer], 11_000_000n, INPUT_FEE) expect(feePayer.balanceCredits - consumed(plan, 'small-but-first')).toBeGreaterThanOrEqual(INPUT_FEE) expect(consumed(plan, 'large-but-second')).toBe(9_000_000n) @@ -141,7 +184,7 @@ describe('selectPlatformInputs', () => { }) it('orders inputs the way consensus does, so DeductFromInput(0) is inputs[0]', () => { - const {plan} = selectPlatformInputs( + const {plan} = inputsFor( [candidate('largest', 9_000_000n, 0, 0x03), candidate('first-by-address', 3_000_000n, 0, 0x01)], 11_000_000n, INPUT_FEE, @@ -157,35 +200,319 @@ describe('selectPlatformInputs', () => { candidate('c', 4_000_000n, 0, 0x03), ] - expect(selectPlatformInputs(candidates, 8_000_000n, INPUT_FEE).error).toMatch(/consolidate/) + expect(inputsFor(candidates, 8_000_000n, INPUT_FEE).error).toMatch(/consolidate/) }) it('keeps the fee out of an explicitly chosen source address', () => { const chosen = candidate('a', 5_000_000n) - const {plan} = selectPlatformInputs([chosen], 4_000_000n, INPUT_FEE, 'a') + const {plan} = inputsFor([chosen], 4_000_000n, INPUT_FEE, from('a')) expect(chosen.balanceCredits - plan!.inputs[0].credits).toBeGreaterThanOrEqual(INPUT_FEE) }) - it('honors the preferred source address', () => { - const {plan} = selectPlatformInputs([candidate('a', 10_000_000n), candidate('b', 7_000_000n)], 5_000_000n, INPUT_FEE, 'b') + it('honors the chosen source address', () => { + const {plan} = inputsFor([candidate('a', 10_000_000n), candidate('b', 7_000_000n)], 5_000_000n, INPUT_FEE, from('b')) expect(plan!.inputs).toHaveLength(1) expect(plan!.inputs[0].candidate.platformAddress).toBe('b') }) - it('throws when the preferred address is unknown', () => { - expect(selectPlatformInputs([candidate('a', 10_000_000n)], 5_000_000n, INPUT_FEE, 'zzz').error).toMatch(/not found/) + it('throws when a chosen address is unknown', () => { + expect(inputsFor([candidate('a', 10_000_000n)], 5_000_000n, INPUT_FEE, from('zzz')).error).toMatch(/insufficient/) }) - it('throws when the preferred address cannot cover amount + fee', () => { - expect(selectPlatformInputs([candidate('a', 5_999_999n)], 5_000_000n, INPUT_FEE, 'a').error).toMatch(/insufficient/) + it('throws when the chosen address cannot cover amount + fee', () => { + expect(inputsFor([candidate('a', 5_999_999n)], 5_000_000n, INPUT_FEE, from('a')).error).toMatch(/insufficient/) }) it('throws when the amount is below the per-input minimum', () => { - expect(selectPlatformInputs([candidate('a', 10_000_000n)], MIN_INPUT_CREDITS - 1n, INPUT_FEE).error).toMatch(/Minimum/) + expect(inputsFor([candidate('a', 10_000_000n)], MIN_INPUT_CREDITS - 1n, INPUT_FEE).error).toMatch(/Minimum/) }) it('throws when the total balance cannot cover the amount', () => { - expect(selectPlatformInputs([candidate('a', 1_000_000n)], 5_000_000n, INPUT_FEE).error).toMatch(/enough credits/) + expect(inputsFor([candidate('a', 1_000_000n)], 5_000_000n, INPUT_FEE).error).toMatch(/enough credits/) + }) +}) + +describe('funding a transition from picked inputs', () => { + const candidates = [ + candidate('a', 5_000_000n), + candidate('b', 5_000_000n), + candidate('c', 5_000_000n), + ] + + // The charged input keeps the fee back out of what it was given, which is the + // only credits an address-funded transition leaves behind. + it('puts in what each input was given, less the fee on the one charged', () => { + const {plan} = inputsFor( + candidates, 3_000_000n, INPUT_FEE, pick([input('a', 2_000_000n), input('b', 2_000_000n)])) + + expect(plan!.inputs.map(entry => [entry.candidate.platformAddress, entry.credits])) + .toEqual([['a', 1_000_000n], ['b', 2_000_000n]]) + }) + + // The picked order is the user's; the built order is the one consensus reads + // a fee index against. + it('orders the inputs by address bytes whatever order they were picked in', () => { + const {plan} = inputsFor( + candidates, 3_000_000n, INPUT_FEE, pick([input('c', 2_000_000n), input('a', 2_000_000n)])) + + expect(plan!.inputs.map(entry => entry.candidate.platformAddress)).toEqual(['a', 'c']) + }) + + it('resolves the fee payer to its position among the sorted inputs', () => { + const {plan} = inputsFor( + candidates, 3_000_000n, INPUT_FEE, + pick([input('c', 2_000_000n), input('a', 2_000_000n)], [{kind: 'deductFromInput', address: 'c'}])) + + expect(plan!.feeStrategy).toEqual([{kind: 'deductFromInput', index: 1}]) + }) + + it('refuses when the picked addresses cannot cover the amount and the fee', () => { + const outcome = inputsFor( + candidates, 3_000_000n, INPUT_FEE, pick([input('a', 2_000_000n)])) + + expect(outcome.error).toMatch(/keep back/) + }) + + // What an input does not draw stays on its address, which is the only change + // an address-funded transition has. + it('draws only what the amount needs, leaving the rest on the addresses', () => { + const {plan} = inputsFor( + candidates, 2_000_000n, INPUT_FEE, pick([input('a', 4_000_000n), input('b', 4_000_000n)])) + + expect(plan!.inputs.reduce((sum, entry) => sum + entry.credits, 0n)).toBe(2_000_000n) + }) + + // A share below the protocol minimum cannot be its own input, so the address + // paying the fee carries it instead. + it('leaves out a picked address whose share would be below the minimum', () => { + const {plan} = inputsFor( + candidates, 2_000_000n, INPUT_FEE, + pick([input('a', 2_000_000n), input('b', MIN_INPUT_CREDITS - 1n)])) + + expect(plan!.inputs.map(entry => entry.candidate.platformAddress)).toEqual(['a']) + }) + + it('refuses an input larger than its address holds', () => { + expect(() => inputsFor(candidates, 6_000_000n, INPUT_FEE, pick([input('a', 6_000_000n)]))) + .toThrow('no longer holds') + }) + + // Each input carries the address's next nonce, so a second one would replay it. + it('refuses the same address twice', () => { + const outcome = inputsFor( + candidates, 2_000_000n, INPUT_FEE, pick([input('a', 1_000_000n), input('a', 1_000_000n)])) + + expect(outcome.error).toMatch(/only once/) + }) + + it('refuses more inputs than a transition takes', () => { + const many = Array.from({length: MAX_ADDRESS_INPUTS + 1}, (_, i) => candidate(`a${i}`, 5_000_000n, 0, i + 1)) + const picked = many.map(entry => input(entry.platformAddress, MIN_INPUT_CREDITS)) + const amount = MIN_INPUT_CREDITS * BigInt(picked.length) + + expect(inputsFor(many, amount, INPUT_FEE, pick(picked)).error).toMatch(/at most/) + }) + + // Consensus refuses an input below the per-input minimum, and a pick funding + // less than one leaves the charged address carrying exactly that. + it('refuses an amount no single input could carry', () => { + const outcome = inputsFor( + candidates, MIN_INPUT_CREDITS - 1n, INPUT_FEE, pick([input('a', 5_000_000n)])) + + expect(outcome.error).toMatch(/Minimum amount/) + }) + + it('refuses an empty pick', () => { + expect(inputsFor(candidates, 1_000_000n, INPUT_FEE, pick([])).error).toMatch(/at least one address/) + }) + + it('refuses an address this wallet does not hold', () => { + expect(() => inputsFor(candidates, 1_000_000n, INPUT_FEE, pick([input('zzz', 1_000_000n)]))) + .toThrow('no longer holds') + }) + + it('refuses when the fee payer does not keep back the fee', () => { + const outcome = inputsFor( + [candidate('a', 1_050_000n)], MIN_INPUT_CREDITS, INPUT_FEE, pick([input('a', 1_050_000n)])) + + expect(outcome.error).toMatch(/keep back/) + }) +}) + +describe('the fee strategy a picked set carries', () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 5_000_000n)] + const picked = [input('a', 2_000_000n), input('b', 1_000_000n)] + + // Nothing is charged, so nothing keeps the fee back and the inputs add up to + // everything they were given. + const UNCHARGED = 3_000_000n + + it('refuses a fee payer that is not one of the inputs', () => { + const outcome = inputsFor( + candidates, UNCHARGED, INPUT_FEE, pick(picked, [{kind: 'deductFromInput', address: 'b2'}])) + + expect(outcome.error).toMatch(/not one of the inputs/) + }) + + it('refuses an empty strategy', () => { + expect(inputsFor(candidates, UNCHARGED, INPUT_FEE, pick(picked, [])).error).toMatch(/must name who pays/) + }) + + it('refuses more steps than a transition takes', () => { + const steps: PlatformFeeStep[] = Array.from( + {length: MAX_FEE_STRATEGY_STEPS + 1}, () => ({kind: 'deductFromInput', address: 'a'})) + + expect(inputsFor(candidates, 2_000_000n, INPUT_FEE, pick(picked, steps)).error).toMatch(/at most/) + }) + + it('refuses a strategy that charges one input twice', () => { + const steps: PlatformFeeStep[] = [ + {kind: 'deductFromInput', address: 'a'}, + {kind: 'deductFromInput', address: 'a'}, + ] + + expect(inputsFor(candidates, 2_000_000n, INPUT_FEE, pick(picked, steps)).error).toMatch(/twice/) + }) + + // These transitions fund a withdrawal script or an identity, not an output a + // fee step can index. + it('refuses reducing an output on an operation that carries none', () => { + const outcome = inputsFor( + candidates, UNCHARGED, INPUT_FEE, pick(picked, [{kind: 'reduceOutput', index: 0}])) + + expect(outcome.error).toMatch(/no output/) + }) + + it('takes the fee out of an output when the operation has one', () => { + const {plan} = inputsFor( + candidates, UNCHARGED, INPUT_FEE, pick(picked, [{kind: 'reduceOutput', index: 0}]), 1) + + expect(plan!.feeStrategy).toEqual([{kind: 'reduceOutput', index: 0}]) + }) + + // Nothing else names an input, so an automatic plan still says index 0 — + // which is the address that sorts first, the one it loaded lightly. + it('charges the first sorted input when the wallet plans the split', () => { + const {plan} = inputsFor(candidates, 2_000_000n, INPUT_FEE) + + expect(plan!.feeStrategy).toEqual([{kind: 'deductFromInput', index: 0}]) + }) +}) + +describe('selectablePlatformInputs', () => { + it('drops addresses that cannot be an input of their own', () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', MIN_INPUT_CREDITS - 1n)] + + expect(selectablePlatformInputs(candidates).map(entry => entry.platformAddress)).toEqual(['a']) + }) + + it('narrows to the address a source names', () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 5_000_000n)] + + expect(selectablePlatformInputs(candidates, from('b')).map(entry => entry.platformAddress)).toEqual(['b']) + }) + + it('yields nothing for an address this wallet does not hold', () => { + expect(selectablePlatformInputs([candidate('a', 5_000_000n)], from('zzz'))).toEqual([]) + }) + + it('refuses a picked address whose balance no longer covers its input', () => { + const candidates = [candidate('a', 1_000_000n)] + + expect(() => selectablePlatformInputs(candidates, pick([input('a', 2_000_000n)]))) + .toThrow('no longer holds') + }) + + it('refuses a picked address this wallet does not hold', () => { + expect(() => selectablePlatformInputs([candidate('a', 5_000_000n)], pick([input('zzz', 1_000_000n)]))) + .toThrow('no longer holds') + }) +}) + +describe('maxPlatformCredits', () => { + const fee = async (inputCount: number): Promise => BigInt(inputCount) * INPUT_FEE + + it('is the best prefix, not the balance minus a fee', async () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 500_000n)] + + // Adding b costs a whole extra fee and brings less than that in. + expect(await maxPlatformCredits(candidates, fee)).toBe(4_000_000n) + }) + + it('adds an address that brings in more than it costs', async () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 4_000_000n)] + + expect(await maxPlatformCredits(candidates, fee)).toBe(7_000_000n) + }) + + it('is zero when the fee outruns every prefix', async () => { + expect(await maxPlatformCredits([candidate('a', 900_000n)], fee)).toBe(0n) + }) + + it('is what a picked set was given, less the fee its count carries', async () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 5_000_000n)] + const source = pick([input('a', 3_000_000n), input('b', 4_000_000n)]) + + expect(await maxPlatformCredits(candidates, fee, source)).toBe(5_000_000n) + }) + + it('offers exactly what a picked set funds', async () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 5_000_000n)] + const source = pick([input('a', 3_000_000n), input('b', 4_000_000n)]) + const max = await maxPlatformCredits(candidates, fee, source) + const {plan} = inputsFor(candidates, max, await fee(2), source) + + expect(plan!.inputs.reduce((sum, entry) => sum + entry.credits, 0n)).toBe(max) + }) + + it('funds exactly what it offers', async () => { + const candidates = [candidate('a', 5_000_000n), candidate('b', 4_000_000n)] + const max = await maxPlatformCredits(candidates, fee) + const {plan} = inputsFor(candidates, max, await fee(2)) + + expect(plan!.inputs.reduce((sum, entry) => sum + entry.credits, 0n)).toBe(max) + }) +}) + +describe('requireAutomaticInputs', () => { + it('accepts an operation that named no source', () => { + expect(() => requireAutomaticInputs(null)).not.toThrow() + }) + + // A pick names platform addresses, so it matches nothing an identity or the + // pool would spend. + it('refuses a pick on an operation platform addresses do not fund', () => { + expect(() => requireAutomaticInputs(from('a'))).toThrow('address-funded operations only') + }) +}) + +describe('requireRecipients', () => { + const to = (address: string, amountCredits: bigint): Recipient => ({address, amountCredits}) + + it('totals what the transition pays out', () => { + expect(requireRecipients([to('a', MIN_OUTPUT_CREDITS), to('b', MIN_OUTPUT_CREDITS * 2n)])) + .toBe(MIN_OUTPUT_CREDITS * 3n) + }) + + it('refuses no recipients at all', () => { + expect(() => requireRecipients([])).toThrow(/between 1 and/) + }) + + it('refuses more recipients than a transition carries', () => { + const many = Array.from({length: MAX_RECIPIENTS + 1}, (_, i) => to(`a${i}`, MIN_OUTPUT_CREDITS)) + + expect(() => requireRecipients(many)).toThrow(/between 1 and/) + }) + + // Consensus keys outputs by address, so a repeated one is a single merged + // payment rather than the two the caller asked for. + it('refuses the same recipient twice', () => { + expect(() => requireRecipients([to('a', MIN_OUTPUT_CREDITS), to('a', MIN_OUTPUT_CREDITS)])) + .toThrow(/only once/) + }) + + it('refuses a recipient below the per-output minimum', () => { + expect(() => requireRecipients([to('a', MIN_OUTPUT_CREDITS - 1n)])).toThrow(/Minimum amount per recipient/) }) }) diff --git a/tests/unit/sendDraft.test.ts b/tests/unit/sendDraft.test.ts index d3dbd71c..62b95de0 100644 --- a/tests/unit/sendDraft.test.ts +++ b/tests/unit/sendDraft.test.ts @@ -37,6 +37,7 @@ describe('send drafts', () => { enabled: true, addresses: { [SourceKind.Core]: 'core-source', + [SourceKind.PlatformAddress]: 'platform-source', [SourceKind.Shielded]: 'shielded-source', }, }, diff --git a/tests/unit/specificSource.test.ts b/tests/unit/specificSource.test.ts index d5d6c3ec..980802d4 100644 --- a/tests/unit/specificSource.test.ts +++ b/tests/unit/specificSource.test.ts @@ -18,9 +18,10 @@ describe('specific source preferences', () => { expect(preferences.enabled).toBe(true) }) - it('keeps Core and Shielded addresses independent when the shared setting is toggled', () => { + it('keeps every source kind\'s address independent when the shared setting is toggled', () => { const withCore = updateSpecificSourceAddress(initialSpecificSourcePreferences(), SourceKind.Core, 'core-address') - const withShielded = updateSpecificSourceAddress(withCore, SourceKind.Shielded, 'shielded-address') + const withPlatform = updateSpecificSourceAddress(withCore, SourceKind.PlatformAddress, 'platform-address') + const withShielded = updateSpecificSourceAddress(withPlatform, SourceKind.Shielded, 'shielded-address') const enabled = updateSpecificSourceEnabled(withShielded, true) const disabled = updateSpecificSourceEnabled(enabled, false) const enabledAgain = updateSpecificSourceEnabled(disabled, true) @@ -29,6 +30,7 @@ describe('specific source preferences', () => { enabled: true, addresses: { [SourceKind.Core]: 'core-address', + [SourceKind.PlatformAddress]: 'platform-address', [SourceKind.Shielded]: 'shielded-address', }, }) @@ -45,9 +47,13 @@ describe('specific source preferences', () => { [TransferOperation.AssetLockShield, SourceKind.Core], [TransferOperation.IdentityRegister, SourceKind.Core], [TransferOperation.IdentityTopUpL1, SourceKind.Core], - // Funded by platform credits, so there is no L1 coin to pick. - [TransferOperation.IdentityCreate, null], - [TransferOperation.AddressWithdrawal, null], + // Funded by platform addresses, whose inputs are what a pick names here. + [TransferOperation.AddressFundsTransfer, SourceKind.PlatformAddress], + [TransferOperation.IdentityCreate, SourceKind.PlatformAddress], + [TransferOperation.IdentityTopUp, SourceKind.PlatformAddress], + [TransferOperation.AddressWithdrawal, SourceKind.PlatformAddress], + // Spends its source address whole, so it has no set to pick from. + [TransferOperation.Shield, null], ])('maps %s to its applicable preference', (operation, expected) => { expect(specificSourceKindForOperation(operation)).toBe(expected) }) diff --git a/tests/unit/transitionFee.test.ts b/tests/unit/transitionFee.test.ts index 7bb1ea98..253627e8 100644 --- a/tests/unit/transitionFee.test.ts +++ b/tests/unit/transitionFee.test.ts @@ -1,6 +1,6 @@ import {describe, it, expect} from 'vitest' import {DashPlatformSDK} from 'dash-platform-sdk' -import {PlatformAddressWASM} from 'pshenmic-dpp' +import {AddressFundsTransferTransitionWASM, PlatformAddressWASM} from 'pshenmic-dpp' import {InputAddressWASM} from 'dash-platform-sdk/types.js' import {createBase58check} from '@scure/base' import {sha256} from '@noble/hashes/sha2.js' @@ -85,7 +85,7 @@ function params(overrides: Partial = {}): FeeQuoteParams { return { amountCredits: 1_000_000n, recipient: IDENTITY, - sourceAddress: null, + platformSource: null, identityId: IDENTITY, shieldedSource: null, inputCount: 1, @@ -141,6 +141,25 @@ describe('transitionFee', () => { expect(twice.feeCredits).toBe(once.feeCredits) }) + // Consensus meters an input like an output, one address balance write each, + // so an input is priced like one. + it('charges an input of a transfer what it charges an output', () => { + const twoInputs = transitionFee({operation: 'addressFundsTransfer', params: params({inputCount: 2})}, ctx) + const oneInput = transitionFee({operation: 'addressFundsTransfer', params: params({inputCount: 1})}, ctx) + const oneOutput = AddressFundsTransferTransitionWASM.estimateMinFee(0, 1) + + expect(twoInputs.feeCredits - oneInput.feeCredits).toBe(oneOutput) + }) + + // Consensus meters the transition rather than counting addresses, and what it + // charged was more than the addresses alone. + it('reserves more than the addresses a transfer touches', () => { + const {feeCredits} = transitionFee({operation: 'addressFundsTransfer', params: params({inputCount: 10})}, ctx) + const touched = AddressFundsTransferTransitionWASM.estimateMinFee(0, 11) + + expect(feeCredits).toBeGreaterThan(touched) + }) + // A bare string is one recipient; nothing has to say so separately. it('prices a single recipient the same whether it is a string or a list of one', () => { const asString = transitionFee({operation: 'identityToAddress', params: params({recipient: PLATFORM_ADDRESS})}, ctx) From ed028212e5d9d9c85d8bc5b361344ea57f1d056a Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 00:22:54 +0300 Subject: [PATCH 09/31] implement output coin control for l1 + pickers placeholders --- src/main/src/api/wallet/sendTransaction.ts | 6 +- src/main/src/constants/chain.ts | 4 + .../services/core/CoreTransactionService.ts | 21 +++-- src/main/src/services/wallet/FeeService.ts | 13 +-- src/main/src/services/wallet/WalletService.ts | 27 +++--- src/main/src/types/CoreTransaction.ts | 17 +++- src/main/src/utils/transferInputs.ts | 17 +++- src/preload/definitions.ts | 6 +- src/preload/index.d.ts | 6 +- src/renderer/src/api/index.ts | 6 +- src/renderer/src/api/types.ts | 7 ++ .../src/components/modal/SendConfirmModal.tsx | 27 ++++-- .../pages/transfer/CoreRecipientsTest.tsx | 65 ++++++++++++++ .../pages/transfer/CoreUtxoPicker.tsx | 87 ++++++++++++++++++ .../components/pages/transfer/TransferHub.tsx | 89 ++++++++++++++++--- src/renderer/src/constants/core.ts | 3 + tests/unit/estimateFee.test.ts | 44 +++++++++ tests/unit/transferInputs.test.ts | 40 +++++++++ 18 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 src/renderer/src/components/pages/transfer/CoreRecipientsTest.tsx create mode 100644 src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx create mode 100644 src/renderer/src/constants/core.ts diff --git a/src/main/src/api/wallet/sendTransaction.ts b/src/main/src/api/wallet/sendTransaction.ts index 748d9622..6be723e1 100644 --- a/src/main/src/api/wallet/sendTransaction.ts +++ b/src/main/src/api/wallet/sendTransaction.ts @@ -1,6 +1,7 @@ import { IpcMainInvokeEvent } from 'electron/utility' import { WalletService } from '../../services/wallet/WalletService' import { CoreSpendSource } from '../../types/CoinSelection' +import { CoreRecipient } from '../../types/CoreTransaction' import { SendResult } from '../../types/SendResult' export class SendTransactionHandler { @@ -13,11 +14,10 @@ export class SendTransactionHandler { handle = async ( _event: IpcMainInvokeEvent, walletId: string, - toAddress: string, - amountDuffs: bigint, + recipients: CoreRecipient[], password: string, source?: CoreSpendSource, ): Promise => { - return this.walletService.sendTransaction(walletId, toAddress, amountDuffs, password, source) + return this.walletService.sendTransaction(walletId, recipients, password, source) } } diff --git a/src/main/src/constants/chain.ts b/src/main/src/constants/chain.ts index a9ff324b..46960bea 100644 --- a/src/main/src/constants/chain.ts +++ b/src/main/src/constants/chain.ts @@ -5,6 +5,10 @@ export const CORE_FEE_PER_BYTE = 1 export const DUST_THRESHOLD_DUFFS = 546n +// Not consensus: a standard transaction may not exceed 100 kB, and at 34 bytes +// per output this keeps a send well inside what peers relay. +export const MAX_CORE_RECIPIENTS = 1_000 + export const ASSET_LOCK_PAYLOAD_BYTES = 37 // A coinbase input names no parent transaction. diff --git a/src/main/src/services/core/CoreTransactionService.ts b/src/main/src/services/core/CoreTransactionService.ts index 6b50a1ed..86b0b210 100644 --- a/src/main/src/services/core/CoreTransactionService.ts +++ b/src/main/src/services/core/CoreTransactionService.ts @@ -88,19 +88,24 @@ export class CoreTransactionService { } async buildSignedTransfer(params: BuildSignedTransferParams): Promise { - const {inputs, toAddress, recipientType, amount, changeAddress, inputTotal, feeDuffs, seed, network} = params + const {inputs, outputs, changeAddress, inputTotal, feeDuffs, seed, network} = params const transaction = new SDKTransaction() const privateKeys = await this.addSignableInputs(transaction, inputs, seed, network) - const recipientOutput = new Output(amount) - if (recipientType === 'p2sh') { - recipientOutput.script = this.p2shScript(toAddress) - } else { - recipientOutput.generateP2PKH(toAddress) + let outputTotal = 0n + for (const output of outputs) { + const recipientOutput = new Output(output.amountDuffs) + if (output.recipientType === 'p2sh') { + recipientOutput.script = this.p2shScript(output.address) + } else { + recipientOutput.generateP2PKH(output.address) + } + transaction.addOutput(recipientOutput) + outputTotal += output.amountDuffs } - transaction.addOutput(recipientOutput) - this.addChange(transaction, inputTotal - amount - feeDuffs, changeAddress) + + this.addChange(transaction, inputTotal - outputTotal - feeDuffs, changeAddress) transaction.sign(privateKeys) return transaction diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index 291d3018..5ed04213 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -75,9 +75,11 @@ export class FeeService { switch (operation) { // Paid in Dash on L1, per byte, so the quote runs the selection the send // will run rather than a floor the send is free to exceed. - case 'coreSend': + case 'coreSend': { requireAutomaticInputs(params.platformSource) - return {feeCredits: null, ...await this.coreQuote(wallet, params, 0), maxPerTx: null, noteLimit: null} + const outputsCount = Array.isArray(params.recipient) ? Math.max(params.recipient.length, 1) : 1 + return {feeCredits: null, ...await this.coreQuote(wallet, params, outputsCount, 0), maxPerTx: null, noteLimit: null} + } // Two transactions, so two fees. The L1 lock is paid in Dash on top of the // amount; the transition its proof funds is paid in credits out of what @@ -89,7 +91,8 @@ export class FeeService { requireAutomaticInputs(params.platformSource) return { feeCredits: await this.protocolFee(wallet, operation, params, 1), - ...await this.coreQuote(wallet, params, ASSET_LOCK_PAYLOAD_BYTES), + // A lock pays its burn output whatever the recipient list names. + ...await this.coreQuote(wallet, params, 1, ASSET_LOCK_PAYLOAD_BYTES), maxPerTx: null, noteLimit: null, } @@ -218,9 +221,9 @@ export class FeeService { // selection over the same coins the send will. maxDuffs is what those coins // can fund at their own price, which is the only amount a Max can offer // without the send refusing it. - private async coreQuote(wallet: Wallet, params: FeeParams, payloadBytes: number): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { + private async coreQuote(wallet: Wallet, params: FeeParams, outputsCount: number, payloadBytes: number): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { const feeForInputs = (inputsCount: number): bigint => - coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, 1, true, payloadBytes) + coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, outputsCount, true, payloadBytes) const source = params.coreSource ?? undefined const grouped = await this.addressDAO.getAddressesByWalletId(wallet.walletId) diff --git a/src/main/src/services/wallet/WalletService.ts b/src/main/src/services/wallet/WalletService.ts index e4751aa0..3d9c1648 100644 --- a/src/main/src/services/wallet/WalletService.ts +++ b/src/main/src/services/wallet/WalletService.ts @@ -30,7 +30,8 @@ import {coreFeeDuffsFor} from '../../utils/coreFeeRate' import {identityPath} from '../../utils/identityKeys' import {coreAccountPath, coreAddressDeriver} from "../../utils/addressDiscovery"; import {CoreSpendSource, SelectableUtxo} from '../../types/CoinSelection' -import {selectableTransferUtxos, selectTransferInputs} from '../../utils/transferInputs' +import {CoreRecipient} from '../../types/CoreTransaction' +import {requireCoreRecipients, selectableTransferUtxos, selectTransferInputs} from '../../utils/transferInputs' import {Preferences} from '../../preferences' import {ConnectionStatus} from '../../types/ConnectionStatus' @@ -313,20 +314,22 @@ export class WalletService { return await provider.getBalance(address) } + // One transaction pays many addresses: each recipient is another output, which + // costs another slice of the per-byte fee rather than another transaction. async sendTransaction( walletId: string, - toAddress: string, - amountDuffs: bigint, + recipients: CoreRecipient[], password: string, source?: CoreSpendSource, ): Promise { - if (amountDuffs <= 0n) { - throw new Error('Send amount must be greater than zero') - } + const amountDuffs = requireCoreRecipients(recipients) const {tx, inputTotal, changeAddress} = await withUnlockedWallet(this.walletDAO, walletId, password, async ({wallet, seed}) => { const network = wallet.network - const recipientType = this.coreTransactionService.classifyRecipientAddress(toAddress, network) + const outputs = recipients.map(recipient => ({ + ...recipient, + recipientType: this.coreTransactionService.classifyRecipientAddress(recipient.address, network), + })) const grouped = await this.addressDAO.getAddressesByWalletId(walletId) const provider = this.providers.forWallet(walletId, network) await provider.ensureReady() @@ -336,15 +339,13 @@ export class WalletService { grouped, await provider.getWalletUtxos(), amountDuffs, - inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, 1, true), + inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, outputs.length, true), source, ) const tx = await this.coreTransactionService.buildSignedTransfer({ inputs: transferInputs, - toAddress, - recipientType, - amount: amountDuffs, + outputs, changeAddress, inputTotal, feeDuffs, @@ -358,13 +359,13 @@ export class WalletService { const outputTotal = tx.outputs.reduce((sum, output) => sum + output.satoshis, 0n) const actualFee = inputTotal - outputTotal - const hasChange = tx.outputs.length > 1 + const hasChange = tx.outputs.length > recipients.length return { txid: broadcast.txid, amount: amountDuffs, fee: actualFee, - toAddress, + toAddress: recipients[0].address, changeAddress: hasChange ? changeAddress : null, peersAcked: broadcast.peersDelivered.length, } diff --git a/src/main/src/types/CoreTransaction.ts b/src/main/src/types/CoreTransaction.ts index 713ec840..58cf4b7b 100644 --- a/src/main/src/types/CoreTransaction.ts +++ b/src/main/src/types/CoreTransaction.ts @@ -11,6 +11,19 @@ export interface TransferInput { address: string } +// One output of a send, as the caller named it. Unlike a platform transition +// nothing is keyed by address, so the same address twice is two payments. +export interface CoreRecipient { + address: string + amountDuffs: bigint +} + +// The same output once its script kind is known, which only the network the +// send runs on can decide. +export interface TransferOutput extends CoreRecipient { + recipientType: RecipientType +} + export interface TransferInputSelection { transferInputs: TransferInput[] inputTotal: bigint @@ -20,9 +33,7 @@ export interface TransferInputSelection { export interface BuildSignedTransferParams { inputs: TransferInput[] - toAddress: string - recipientType: RecipientType - amount: bigint + outputs: TransferOutput[] changeAddress: string inputTotal: bigint feeDuffs: bigint diff --git a/src/main/src/utils/transferInputs.ts b/src/main/src/utils/transferInputs.ts index b503aa3e..e0c5fa80 100644 --- a/src/main/src/utils/transferInputs.ts +++ b/src/main/src/utils/transferInputs.ts @@ -1,7 +1,8 @@ import {GroupedAddresses} from '../types/GroupedAddresses' import {CoreFeeForInputs, CoreSpendSource, SelectableUtxo} from '../types/CoinSelection' -import {TransferInput, TransferInputSelection} from '../types/CoreTransaction' +import {CoreRecipient, TransferInput, TransferInputSelection} from '../types/CoreTransaction' import {UTXO} from '../types/UTXO' +import {DUST_THRESHOLD_DUFFS, MAX_CORE_RECIPIENTS} from '../constants/chain' import {selectCoins} from './coinSelection' const outpointKey = (txid: string, vout: number): string => `${txid}:${vout}` @@ -11,6 +12,20 @@ const pickedOutpointKeys = (source?: CoreSpendSource): Set | null => ? new Set(source.outpoints.map(outpoint => outpointKey(outpoint.txid, outpoint.vout))) : null +// Every output a send carries, and the amount it has to fund. An output under +// the dust threshold is one no peer relays, so it is refused rather than sent. +export function requireCoreRecipients(recipients: CoreRecipient[]): bigint { + if (recipients.length === 0 || recipients.length > MAX_CORE_RECIPIENTS) { + throw new Error(`Recipient count must be between 1 and ${MAX_CORE_RECIPIENTS}`) + } + for (const recipient of recipients) { + if (recipient.amountDuffs < DUST_THRESHOLD_DUFFS) { + throw new Error(`Minimum amount per recipient is ${DUST_THRESHOLD_DUFFS.toString()} duffs`) + } + } + return recipients.reduce((sum, recipient) => sum + recipient.amountDuffs, 0n) +} + // Falls back to the last change address, then to a receiving one, so change // never leaves the wallet. export function pickChangeAddress(grouped: GroupedAddresses): string { diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 6db319c5..3968458e 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -8,6 +8,10 @@ type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } +// Mirrors the CoreRecipient in src/main/src/types/CoreTransaction: one +// transaction can pay many addresses, each its own amount. +type CoreRecipient = { address: string; amountDuffs: bigint } + // Mirrors src/main/src/types/ShieldedNoteSelection: an address narrows the // automatic note selection, a picked note list is spent whole. type ShieldedSpendSource = @@ -51,7 +55,7 @@ export const apiDefinitions = (ipcRenderer) => ({ addPlatformAddress: (walletId: string) => ipcRenderer.invoke('addPlatformAddress', walletId), setAddressLabel: (walletId: string, address: string, label: string) => ipcRenderer.invoke('setAddressLabel', walletId, address, label), setWalletLabel: (walletId: string, label: string | null) => ipcRenderer.invoke('setWalletLabel', walletId, label), - sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => ipcRenderer.invoke('sendTransaction', walletId, toAddress, amountDuffs, password, source), + sendTransaction: (walletId: string, recipients: CoreRecipient[], password: string, source?: CoreSpendSource) => ipcRenderer.invoke('sendTransaction', walletId, recipients, password, source), getTxLockStatus: (walletId: string, txid: string) => ipcRenderer.invoke('getTxLockStatus', walletId, txid), estimateFee: (walletId: string, operation: string, params: unknown) => ipcRenderer.invoke('estimateFee', walletId, operation, params), sendPlatformTransfer: (walletId: string, source: PlatformSpendSource | null, recipients: PlatformRecipient[], password: string) => ipcRenderer.invoke('sendPlatformTransfer', walletId, source, recipients, password), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c3484587..c2749267 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -10,6 +10,10 @@ type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: { txid: string; vout: number }[] } +// Mirrors the CoreRecipient in src/main/src/types/CoreTransaction: one +// transaction can pay many addresses, each its own amount. +type CoreRecipient = { address: string; amountDuffs: bigint } + // Mirrors src/main/src/types/ShieldedNoteSelection: an address narrows the // automatic note selection, a picked note list is spent whole. type ShieldedSpendSource = @@ -109,7 +113,7 @@ declare global { getWalletBalance: (walletId: string) => Promise setAddressLabel: (walletId: string, address: string, label: string) => Promise setWalletLabel: (walletId: string, label: string | null) => Promise - sendTransaction: (walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource) => Promise + sendTransaction: (walletId: string, recipients: CoreRecipient[], password: string, source?: CoreSpendSource) => Promise getTxLockStatus: (walletId: string, txid: string) => Promise estimateFee: (walletId: string, operation: string, params: unknown) => Promise<{ feeCredits: bigint | null; feeDuffs: bigint | null; maxDuffs: bigint | null; maxPerTx: bigint | null; noteLimit: number | null }> sendPlatformTransfer: (walletId: string, source: PlatformSpendSource | null, recipients: PlatformRecipient[], password: string) => Promise diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 40576dd1..5a1709ba 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,6 +1,6 @@ import { WalletTxDto } from '@renderer/types/WalletTransaction' import { TransferOperation } from '../enums/TransferOperation' -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformRecipient, PlatformSendResult, PlatformSpendSource, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreRecipient, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformRecipient, PlatformSendResult, PlatformSpendSource, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' export class API { private static get api() { @@ -163,8 +163,8 @@ export class API { return this.api.deleteContact(id) } - static async sendTransaction(walletId: string, toAddress: string, amountDuffs: bigint, password: string, source?: CoreSpendSource): Promise { - return this.api.sendTransaction(walletId, toAddress, amountDuffs, password, source) as Promise + static async sendTransaction(walletId: string, recipients: CoreRecipient[], password: string, source?: CoreSpendSource): Promise { + return this.api.sendTransaction(walletId, recipients, password, source) as Promise } static async getTxLockStatus(walletId: string, txid: string): Promise { diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index 3d8d1ab1..0c2c3cca 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -58,6 +58,13 @@ export type CoreSpendSource = | { kind: 'address'; address: string } | { kind: 'outpoints'; outpoints: Outpoint[] } +// One output of a send. Nothing is keyed by address, so the same address twice +// is two payments. +export interface CoreRecipient { + address: string + amountDuffs: bigint +} + // An address narrows the automatic note selection; a picked note list is the // spend set itself, spent whole. export type ShieldedSpendSource = diff --git a/src/renderer/src/components/modal/SendConfirmModal.tsx b/src/renderer/src/components/modal/SendConfirmModal.tsx index 86608a6e..a63a07a9 100644 --- a/src/renderer/src/components/modal/SendConfirmModal.tsx +++ b/src/renderer/src/components/modal/SendConfirmModal.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom' import { Button, CrossIcon, Input, Text, SuccessIcon, CheckIcon } from '../dash-ui-kit-enxtended' import { useTheme } from 'dash-ui-kit/react' import { API } from '@renderer/api' -import { CoreSpendSource, Network, SendResult, TxLockStatus } from '@renderer/api/types' +import { CoreRecipient, CoreSpendSource, Network, SendResult, TxLockStatus } from '@renderer/api/types' import { ConfirmModalPhase } from '@renderer/enums/ConfirmModalPhase' import { SendLockPhase } from '@renderer/enums/SendLockPhase' import { davToDash } from '@renderer/utils/balance' @@ -19,8 +19,7 @@ interface SendConfirmModalProps { onClose: () => void walletId: string | null network: Network | null - toAddress: string - amountDuffs: bigint + recipients: CoreRecipient[] amountFiat?: string source?: CoreSpendSource onSuccess: () => void @@ -43,13 +42,13 @@ export default function SendConfirmModal({ onClose, walletId, network, - toAddress, - amountDuffs, + recipients, amountFiat, source, onSuccess, }: SendConfirmModalProps): React.JSX.Element | null { const { theme } = useTheme() + const amountDuffs = recipients.reduce((sum, recipient) => sum + recipient.amountDuffs, 0n) const [password, setPassword] = useState('') const [phase, setPhase] = useState(ConfirmModalPhase.Confirm) const [lockPhase, setLockPhase] = useState(SendLockPhase.Waiting) @@ -111,7 +110,7 @@ export default function SendConfirmModal({ setPhase(ConfirmModalPhase.Confirm) return } - const res = await API.sendTransaction(walletId, toAddress, amountDuffs, password, source) + const res = await API.sendTransaction(walletId, recipients, password, source) setResult(res) setPhase(ConfirmModalPhase.Done) onSuccess() @@ -164,7 +163,13 @@ export default function SendConfirmModal({ )}
To - {toAddress} +
+ {recipients.map((recipient, index) => ( + + {recipient.address} + + ))} +
@@ -248,7 +253,13 @@ export default function SendConfirmModal({
To - {toAddress} +
+ {recipients.map((recipient, index) => ( + + {recipient.address} + + ))} +
Network fee diff --git a/src/renderer/src/components/pages/transfer/CoreRecipientsTest.tsx b/src/renderer/src/components/pages/transfer/CoreRecipientsTest.tsx new file mode 100644 index 00000000..04429c75 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/CoreRecipientsTest.tsx @@ -0,0 +1,65 @@ +import { Text } from "@renderer/components/dash-ui-kit-enxtended"; + +// TEST ONLY, to be reverted. It names addresses alone: the amount stays the one +// typed on the amount step, so the rest of the send flow is untouched. +interface CoreRecipientsTestProps { + addresses: string[] + onChange: (addresses: string[]) => void + maxRecipients: number +} + +export default function CoreRecipientsTest({addresses, onChange, maxRecipients}: CoreRecipientsTestProps): React.JSX.Element { + return ( +
+
+ + Extra recipients {addresses.length}/{maxRecipients} (test) + +
+ + {addresses.length > 0 && ( + + )} +
+
+ + {addresses.map((address, index) => ( +
+ onChange(addresses.map((entry, i) => (i === index ? e.target.value : entry)))} + placeholder={"Dash address"} + className={"flex-1 min-w-0 bg-transparent outline-none dash-text-default placeholder:opacity-30 text-[.8125rem] font-mono"} + /> + +
+ ))} + + {addresses.length > 0 && ( + + The amount you type is split evenly between the recipient above and + these, with any remainder going to the first. + + )} +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx b/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx new file mode 100644 index 00000000..0a4ed471 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx @@ -0,0 +1,87 @@ +import { Text } from "@renderer/components/dash-ui-kit-enxtended"; +import { DashLogo } from "dash-ui-kit/react"; +import Checkbox from "@renderer/components/ui/Checkbox"; +import { SelectableUtxo } from "@renderer/api/types"; +import { davToDash, davToDashCompact } from "@renderer/utils/balance"; + +export const outpointKey = (utxo: {txid: string; vout: number}): string => `${utxo.txid}:${utxo.vout}` + +// TEST ONLY, to be reverted. +interface CoreUtxoPickerProps { + utxos: SelectableUtxo[] + picked: string[] + onToggle: (key: string, checked: boolean) => void + onClear: () => void +} + +export default function CoreUtxoPicker({utxos, picked, onToggle, onClear}: CoreUtxoPickerProps): React.JSX.Element { + const chosen = new Set(picked) + const total = utxos + .filter(utxo => chosen.has(outpointKey(utxo))) + .reduce((sum, utxo) => sum + utxo.satoshis, 0n) + + return ( +
+
+ + Picked {picked.length}/{utxos.length} coins (test) + +
+ {davToDash(total)} Dash + {picked.length > 0 && ( + + )} +
+
+ +
+ {utxos.length === 0 && ( + + No spendable coins + + )} + {utxos.map(utxo => { + const key = outpointKey(utxo) + const isPicked = chosen.has(key) + + return ( +
+ onToggle(key, next)} + label={ +
+ +
+ + {utxo.txid.slice(0, 12)}…:{utxo.vout} + + + {davToDashCompact(utxo.satoshis)} Dash · {utxo.address.slice(0, 10)}… + {utxo.height === 0 && ' · pending'} + +
+
+ } + /> +
+ ) + })} +
+ + + Picked coins are spent whole and override the address above; the fee and + the change come out of them. Pick nothing to let the wallet choose. + +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 260b5fdd..dd25cbf0 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -8,7 +8,10 @@ import CreditsAmount from "@renderer/components/ui/CreditsAmount"; import Checkbox from "@renderer/components/ui/Checkbox"; import PlatformInputPicker from "./PlatformInputPicker"; import PlatformRecipientsTest from "./PlatformRecipientsTest"; +import CoreRecipientsTest from "./CoreRecipientsTest"; +import CoreUtxoPicker, { outpointKey } from "./CoreUtxoPicker"; import { PLATFORM_INPUT_LIMIT, PLATFORM_RECIPIENT_LIMIT } from "@renderer/constants/platform"; +import { CORE_RECIPIENT_LIMIT } from "@renderer/constants/core"; import ProverPill from "@renderer/components/pages/shielded/ProverPill"; import Spinner from "@renderer/components/ui/Spinner"; import { useAuth } from "@renderer/contexts/AuthContext"; @@ -51,7 +54,7 @@ import { ShieldedSpendPhase } from "@renderer/enums/ShieldedSpendPhase"; import { AssetLockFundingPhase } from "@renderer/enums/AssetLockFundingPhase"; import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; -import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, PlatformSpendSource, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; +import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, PlatformSpendSource, SelectableUtxo, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; import type { SendDraft } from "@renderer/types/SendDraft"; import type { SpecificSourcePreferences } from "@renderer/types/SpecificSource"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; @@ -105,6 +108,9 @@ function WalletTransferHub(): React.JSX.Element { specificSourcePreferences: update(current.specificSourcePreferences), })) const [testRecipients, setTestRecipients] = useState([]) + const [testCoreRecipients, setTestCoreRecipients] = useState([]) + const [utxos, setUtxos] = useState([]) + const [pickedOutpoints, setPickedOutpoints] = useState([]) const [pickedPlatformInputs, setPickedPlatformInputs] = useState([]) const [platformFeeAddress, setPlatformFeeAddress] = useState(null) const [confirmOpen, setConfirmOpen] = useState(false) @@ -129,6 +135,15 @@ function WalletTransferHub(): React.JSX.Element { return () => { dead = true } }, [walletId, wizardKey, fundingRefresh]) + useEffect(() => { + if (!walletId) return + let dead = false + API.getUtxos(walletId) + .then(loaded => { if (!dead) setUtxos(loaded) }) + .catch(() => {}) + return () => { dead = true } + }, [walletId, wizardKey]) + const dismissFunding = async (): Promise => { if (!walletId || dismissBusy) return setDismissBusy(true) @@ -198,9 +213,18 @@ function WalletTransferHub(): React.JSX.Element { ) const selectedCoreAddress = coreAddresses.find(a => a.address === specificSourcePreferences.addresses[SourceKind.Core]) ?? coreAddresses[0] const coreSpecificAddress = specificSourceKind === SourceKind.Core && useSpecificSource ? selectedCoreAddress : undefined - const coreSpendSource: CoreSpendSource | undefined = coreSpecificAddress - ? { kind: 'address', address: coreSpecificAddress.address } - : undefined + const corePicking = specificSourceKind === SourceKind.Core && useSpecificSource + const pickedUtxos = useMemo( + () => (corePicking ? utxos.filter(utxo => pickedOutpoints.includes(outpointKey(utxo))) : []), + [corePicking, utxos, pickedOutpoints], + ) + // A pick names the coins themselves, which is the only way a send reaches for + // ones an amount would have stopped short of. + const coreSpendSource: CoreSpendSource | undefined = pickedUtxos.length > 0 + ? { kind: 'outpoints', outpoints: pickedUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout })) } + : coreSpecificAddress + ? { kind: 'address', address: coreSpecificAddress.address } + : undefined const spendableNotes = useMemo( () => (shieldedSync.phase === ShieldedSyncPhase.Done ? shieldedSync.notes.filter(n => !n.spent) : []) @@ -268,7 +292,9 @@ function WalletTransferHub(): React.JSX.Element { [specificSourceKind, platformPicking, pickedPlatformAddresses, platformFeePayer, selectedSource?.platformAddress], ) - const balanceDuffs = coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount + const balanceDuffs = pickedUtxos.length > 0 + ? pickedUtxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) + : coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount const shieldedBalance = shieldedSync.phase === ShieldedSyncPhase.Done && shieldedSync.balance !== null ? BigInt(shieldedSync.balance) : null const availableCredits: bigint | null = @@ -301,9 +327,27 @@ function WalletTransferHub(): React.JSX.Element { [trimmedTo, testRecipients, amountCredits], ) + // TEST ONLY. The same split on L1, where each extra address is another output + // paid by the same transaction. + const manyCoreRecipients = operation === TransferOperation.CoreSend && testCoreRecipients.length > 0 + const coreRecipientList = useMemo( + () => { + const addresses = [trimmedTo, ...testCoreRecipients.map(entry => entry.trim())].filter(entry => entry.length > 0) + const share = addresses.length === 0 ? 0n : amountDuffs / BigInt(addresses.length) + return addresses.map((address, index) => ({ + address, + amountDuffs: index === 0 ? amountDuffs - share * BigInt(addresses.length - 1) : share, + })) + }, + [trimmedTo, testCoreRecipients, amountDuffs], + ) + const destinationValid = manyRecipients ? recipientList.length === testRecipients.length + 1 && recipientList.every(entry => isValidPlatformAddress(entry.address, network ?? undefined)) + : manyCoreRecipients + ? coreRecipientList.length === testCoreRecipients.length + 1 + && coreRecipientList.every(entry => isValidDashAddress(entry.address, network ?? undefined)) : toKind === DestinationKind.CoreAddress ? isValidDashAddress(trimmedTo, network ?? undefined) : toKind === DestinationKind.PlatformAddress ? isValidPlatformAddress(trimmedTo, network ?? undefined) : toKind === DestinationKind.Identity ? isLikelyIdentityId(trimmedTo) @@ -312,7 +356,9 @@ function WalletTransferHub(): React.JSX.Element { const { feeCredits, feeDuffs, maxDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr } = useOperationFee(walletId, operation, { destinationValid, - recipient: manyRecipients ? recipientList.map(entry => entry.address) : trimmedTo, + recipient: manyRecipients ? recipientList.map(entry => entry.address) + : manyCoreRecipients ? coreRecipientList.map(entry => entry.address) + : trimmedTo, amountCredits, amountDuffs: isCoreOperation ? amountDuffs : null, coreSource: coreSpendSource ?? null, @@ -439,6 +485,8 @@ function WalletTransferHub(): React.JSX.Element { setPickedPlatformInputs([]) setPlatformFeeAddress(null) setTestRecipients([]) + setTestCoreRecipients([]) + setPickedOutpoints([]) const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false } draftRef.current = resetDraft setDraftState(resetDraft) @@ -479,11 +527,27 @@ function WalletTransferHub(): React.JSX.Element { } /> {useSpecificSource && specificSourceKind === SourceKind.Core && ( - setSpecificSourcePreferences(current => - updateSpecificSourceAddress(current, SourceKind.Core, address))} + <> + setSpecificSourcePreferences(current => + updateSpecificSourceAddress(current, SourceKind.Core, address))} + /> + setPickedOutpoints(current => + checked ? [...current, key] : current.filter(entry => entry !== key))} + onClear={() => setPickedOutpoints([])} + /> + + )} + {operation === TransferOperation.CoreSend && ( + )} {operation === TransferOperation.AddressFundsTransfer && ( @@ -898,8 +962,7 @@ function WalletTransferHub(): React.JSX.Element { onClose={() => setConfirmOpen(false)} walletId={walletId} network={network} - toAddress={trimmedTo} - amountDuffs={amountDuffs} + recipients={coreRecipientList} amountFiat={amountFiat} source={coreSpendSource} onSuccess={() => { diff --git a/src/renderer/src/constants/core.ts b/src/renderer/src/constants/core.ts new file mode 100644 index 00000000..8d8fe1ee --- /dev/null +++ b/src/renderer/src/constants/core.ts @@ -0,0 +1,3 @@ +// Not consensus: a standard transaction may not exceed 100 kB, and at 34 bytes +// per output this keeps a send well inside what peers relay. +export const CORE_RECIPIENT_LIMIT = 1_000 diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index 78afbab1..fd000f9c 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -29,6 +29,8 @@ const ONE_DASH = 100_000_000n const CORE_FEE = (inputsCount: number): bigint => coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true) +const CORE_FEE_FOR = (inputsCount: number, outputsCount: number): bigint => + coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, outputsCount, true) const ASSET_LOCK_FEE = (inputsCount: number): bigint => coreFeeDuffsFor(DEFAULT_CORE_FEE_MULTIPLIER, inputsCount, 1, true, ASSET_LOCK_PAYLOAD_BYTES) @@ -256,6 +258,48 @@ describe('estimateFee', () => { expect(fee.feeDuffs).toBe(CORE_FEE(2)) }) + // Every extra recipient is another output on the same transaction, and the + // fee is per byte, so quoting one output would underprice all but a plain send. + it('prices a Core send for every output it carries', async () => { + const {service: svc} = service([], [utxo(ONE_DASH, 1)]) + + const fee = await svc.estimateFee(WALLET, 'coreSend', params({ + amountDuffs: 1_000n, + recipient: [CORE_ADDRESS, CORE_ADDRESS, CORE_ADDRESS], + })) + + expect(fee.feeDuffs).toBe(CORE_FEE_FOR(1, 3)) + expect(fee.feeDuffs).toBeGreaterThan(CORE_FEE(1)) + }) + + // Max is what the send can still fund, and the outputs it will carry are part + // of that price. + it('offers a smaller maximum the more outputs the send carries', async () => { + const {service: svc} = service([], [utxo(ONE_DASH, 1)]) + + const one = await svc.estimateFee(WALLET, 'coreSend', params({amountDuffs: 0n})) + const many = await svc.estimateFee(WALLET, 'coreSend', params({ + amountDuffs: 0n, + recipient: [CORE_ADDRESS, CORE_ADDRESS, CORE_ADDRESS], + })) + + expect(one.maxDuffs).toBe(ONE_DASH - CORE_FEE_FOR(1, 1)) + expect(many.maxDuffs).toBe(ONE_DASH - CORE_FEE_FOR(1, 3)) + }) + + // An asset lock pays its burn output and its change, whatever the caller + // named, so the recipient list cannot move its price. + it('prices an asset lock for one output whatever the recipient list says', async () => { + const {service: svc} = service([], [utxo(ONE_DASH, 1)]) + + const fee = await svc.estimateFee(WALLET, 'assetLockFunding', params({ + amountDuffs: 1_000n, + recipient: [CORE_ADDRESS, CORE_ADDRESS, CORE_ADDRESS], + })) + + expect(fee.feeDuffs).toBe(ASSET_LOCK_FEE(1)) + }) + // An asset lock is funded by L1 coins, so a platform address names nothing it // could spend — refused rather than ignored. it('refuses a platform input pick on an asset lock', async () => { diff --git a/tests/unit/transferInputs.test.ts b/tests/unit/transferInputs.test.ts index 969f0a8b..99da8dda 100644 --- a/tests/unit/transferInputs.test.ts +++ b/tests/unit/transferInputs.test.ts @@ -6,9 +6,11 @@ import {UTXO} from '../../src/main/src/types/UTXO' import { pickChangeAddress, pickCreditChangeAddress, + requireCoreRecipients, selectTransferInputs, } from '../../src/main/src/utils/transferInputs' import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' +import {DUST_THRESHOLD_DUFFS, MAX_CORE_RECIPIENTS} from '../../src/main/src/constants/chain' const SCRIPT_HEX = '76a9143a2d4145a4f098523b3e8127f1da87cfc55b8e7988ac' // No derivation path in the wallet, so nothing here can be signed. @@ -158,3 +160,41 @@ describe('choosing the asset lock credit address', () => { .toThrow('no change address for the asset lock credit output') }) }) + +describe('the recipients a send is allowed to pay', () => { + const recipient = (address: string, amountDuffs: bigint): {address: string; amountDuffs: bigint} => + ({address, amountDuffs}) + + it('funds the sum of every output', () => { + expect(requireCoreRecipients([ + recipient('recv-0', 10_000n), + recipient('recv-1', 25_000n), + ])).toBe(35_000n) + }) + + it('refuses a send with nothing to pay', () => { + expect(() => requireCoreRecipients([])).toThrow(/between 1 and/) + }) + + it('refuses more outputs than a standard transaction carries', () => { + const many = Array.from({length: MAX_CORE_RECIPIENTS + 1}, () => recipient('recv-0', 10_000n)) + expect(() => requireCoreRecipients(many)).toThrow(/between 1 and/) + }) + + // An output under the dust threshold makes the whole transaction non-standard, + // so it is refused here rather than by every peer it is offered to. + it('refuses an output below the dust threshold', () => { + expect(() => requireCoreRecipients([recipient('recv-0', DUST_THRESHOLD_DUFFS - 1n)])) + .toThrow(/Minimum amount per recipient/) + expect(requireCoreRecipients([recipient('recv-0', DUST_THRESHOLD_DUFFS)])).toBe(DUST_THRESHOLD_DUFFS) + }) + + // Nothing on L1 is keyed by address, so the same address twice is two + // payments rather than one merged one. + it('lets one address be paid twice', () => { + expect(requireCoreRecipients([ + recipient('recv-0', 10_000n), + recipient('recv-0', 10_000n), + ])).toBe(20_000n) + }) +}) From 962186ba7b5a85116f4f1cda00c46fe04bd9799e Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 15:37:53 +0300 Subject: [PATCH 10/31] implement output coin control for shielded + pickers placeholders --- src/main/platform/constants.ts | 4 + src/main/platform/operations/address/infos.ts | 19 ++-- src/main/platform/operations/fee.ts | 14 +-- .../operations/shielded/checkSpent.ts | 3 +- .../platform/operations/shielded/constants.ts | 8 -- .../shielded/reads/checkNullifiers.ts | 3 +- .../shielded/reads/nullifierStatuses.ts | 17 ++++ .../shielded/spend/buildTransition.ts | 34 +++++-- .../platform/operations/shielded/spend/fee.ts | 8 +- .../operations/shielded/spend/spend.ts | 14 ++- src/main/platform/types/messages.ts | 8 +- .../src/api/shielded/startShieldedTransfer.ts | 6 +- src/main/src/constants/credits.ts | 11 +++ .../src/services/platform/ShieldedService.ts | 42 +++++---- src/main/src/services/wallet/FeeService.ts | 7 +- src/main/src/types/ShieldedNoteSelection.ts | 7 ++ src/main/src/utils/shieldedNoteSelection.ts | 22 +++++ src/preload/definitions.ts | 6 +- src/preload/index.d.ts | 6 +- src/renderer/src/api/index.ts | 6 +- src/renderer/src/api/types.ts | 7 ++ .../pages/transfer/ShieldedNotePicker.tsx | 88 +++++++++++++++++++ .../pages/transfer/ShieldedRecipientsTest.tsx | 65 ++++++++++++++ .../components/pages/transfer/TransferHub.tsx | 74 ++++++++++++++-- src/renderer/src/constants/shielded.ts | 7 ++ tests/unit/estimateFee.test.ts | 14 ++- tests/unit/shieldedNoteSelection.test.ts | 85 +++++++++++++++++- tests/unit/shieldedSeedZeroing.test.ts | 10 +-- 28 files changed, 516 insertions(+), 79 deletions(-) create mode 100644 src/main/platform/operations/shielded/reads/nullifierStatuses.ts create mode 100644 src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx create mode 100644 src/renderer/src/components/pages/transfer/ShieldedRecipientsTest.tsx diff --git a/src/main/platform/constants.ts b/src/main/platform/constants.ts index 5c0ff2c4..c35c2acd 100644 --- a/src/main/platform/constants.ts +++ b/src/main/platform/constants.ts @@ -24,6 +24,10 @@ export const FEE_QUOTE_PUBLIC_KEY = Uint8Array.from( // bincode encoding of PlatformAddress::P2pkh: one variant byte, then the hash. export const PLATFORM_ADDRESS_BYTES = 21 +// Drive caps a proved query at max_returned_elements, and rejects the request +// rather than truncating it. Versioned, so it can move under a protocol bump. +export const PROVED_QUERY_LIMIT = 100 + export const KEY_SPECS: Array<{purpose: 'AUTHENTICATION' | 'TRANSFER'; securityLevel: 'MASTER' | 'HIGH' | 'CRITICAL'}> = [ {purpose: 'AUTHENTICATION', securityLevel: 'MASTER'}, {purpose: 'AUTHENTICATION', securityLevel: 'HIGH'}, diff --git a/src/main/platform/operations/address/infos.ts b/src/main/platform/operations/address/infos.ts index 63034afc..6b16db03 100644 --- a/src/main/platform/operations/address/infos.ts +++ b/src/main/platform/operations/address/infos.ts @@ -1,26 +1,31 @@ import {PlatformOperations} from '../../types/messages' import {OperationContext} from '../types' +import {PROVED_QUERY_LIMIT} from '../../constants' type Payload = PlatformOperations['addressInfos']['payload'] type Result = PlatformOperations['addressInfos']['result'] -// One proof-verified batch, no per-address fallback: the proof either covers -// the whole query or the call throws, so an address absent from the answer is +// Proof-verified batches, no per-address fallback: each page's proof either +// covers that page or the call throws, so an address absent from the answer is // absent from state — never an address we failed to ask about (finding R-4). +// Drive rejects a page over the cap rather than truncating it, so a wallet past +// one page can only be asked a page at a time. export async function addressInfos(payload: Payload, ctx: OperationContext): Promise { const {addresses} = payload if (addresses.length === 0) return {infos: []} const {sdk, network} = ctx - const batch = await sdk.platformAddresses.getAddressesInfos(addresses) - - return { - infos: batch + const infos: Result['infos'] = [] + for (let start = 0; start < addresses.length; start += PROVED_QUERY_LIMIT) { + const batch = await sdk.platformAddresses.getAddressesInfos(addresses.slice(start, start + PROVED_QUERY_LIMIT)) + infos.push(...batch .filter(info => info.address != null) .map(info => ({ address: info.address.toBech32m(network), balance: info.balance, nonce: info.nonce, - })), + }))) } + + return {infos} } \ No newline at end of file diff --git a/src/main/platform/operations/fee.ts b/src/main/platform/operations/fee.ts index bba8f9b5..a053c334 100644 --- a/src/main/platform/operations/fee.ts +++ b/src/main/platform/operations/fee.ts @@ -24,19 +24,23 @@ import {OperationContext} from './types' import {buildAssetLockProof} from './assetLockProof' import {DEDUCT_FROM_FIRST} from './address/signInputs' import {minimumFee} from './shielded/spend/fee' -import {MAX_SPEND_NOTES, MIN_BUNDLE_ACTIONS} from './shielded/constants' -import {IDENTITY_KEY_DEFINITIONS, SHIELD_FUNDING_FEE_RESERVE_CREDITS} from '../../src/constants/credits' +import { + IDENTITY_KEY_DEFINITIONS, + MAX_BUNDLE_ACTIONS, + MIN_BUNDLE_ACTIONS, + SHIELD_FUNDING_FEE_RESERVE_CREDITS, +} from '../../src/constants/credits' type Payload = PlatformOperations['transitionFee']['payload'] type Result = PlatformOperations['transitionFee']['result'] type CurvePayload = PlatformOperations['spendFeeCurve']['payload'] type CurveResult = PlatformOperations['spendFeeCurve']['result'] -// A spend's fee and its note count define each other, so the caller needs the -// whole curve to resolve them rather than one point on it. +// Notes spent and addresses paid both land on the action count, and the fee +// follows only that, so one curve over every action count answers for both. export function spendFeeCurve(payload: CurvePayload): CurveResult { return { - feeCredits: Array.from({length: MAX_SPEND_NOTES}, (_, index) => minimumFee(payload.kind, index + 1)), + feeCredits: Array.from({length: MAX_BUNDLE_ACTIONS}, (_, index) => minimumFee(payload.kind, index + 1)), } } diff --git a/src/main/platform/operations/shielded/checkSpent.ts b/src/main/platform/operations/shielded/checkSpent.ts index 4c5b1157..c509cd01 100644 --- a/src/main/platform/operations/shielded/checkSpent.ts +++ b/src/main/platform/operations/shielded/checkSpent.ts @@ -2,6 +2,7 @@ import {DashPlatformSDK} from 'dash-platform-sdk' import {RecoveredNoteWASM} from 'pshenmic-dpp' import {CheckedNote} from '../../types/service' +import {nullifierStatuses} from './reads/nullifierStatuses' const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex') @@ -14,7 +15,7 @@ export async function checkSpent( ): Promise { if (recovered.length === 0) return [] - const statuses = await sdk.shielded.getShieldedNullifiers(recovered.map(note => note.nullifier)) + const statuses = await nullifierStatuses(sdk, recovered.map(note => note.nullifier)) const byNullifier = new Map(statuses.map(status => [hex(status.nullifier), status.isSpent])) return recovered.map(recoveredNote => ({recoveredNote, spent: byNullifier.get(hex(recoveredNote.nullifier)) === true})) diff --git a/src/main/platform/operations/shielded/constants.ts b/src/main/platform/operations/shielded/constants.ts index 60d4b993..5458447c 100644 --- a/src/main/platform/operations/shielded/constants.ts +++ b/src/main/platform/operations/shielded/constants.ts @@ -1,9 +1 @@ export const SHIELD_FUNDING_DUMMY_OUTPUTS = 1 - -// Platform caps state transitions at ~20KB and the Halo2 proof grows with the -// number of Orchard actions. -export const MAX_SPEND_NOTES = 6 - -// An Orchard bundle carries at least two actions, so a single-note spend is -// still charged for two. -export const MIN_BUNDLE_ACTIONS = 2 \ No newline at end of file diff --git a/src/main/platform/operations/shielded/reads/checkNullifiers.ts b/src/main/platform/operations/shielded/reads/checkNullifiers.ts index 2dd22257..260d00c7 100644 --- a/src/main/platform/operations/shielded/reads/checkNullifiers.ts +++ b/src/main/platform/operations/shielded/reads/checkNullifiers.ts @@ -1,5 +1,6 @@ import {PlatformOperations} from '../../../types/messages' import {OperationContext} from '../../types' +import {nullifierStatuses} from './nullifierStatuses' type Payload = PlatformOperations['checkNullifiers']['payload'] type Result = PlatformOperations['checkNullifiers']['result'] @@ -9,6 +10,6 @@ type Result = PlatformOperations['checkNullifiers']['result'] export async function checkNullifiers(payload: Payload, ctx: OperationContext): Promise { if (payload.nullifiers.length === 0) return {spent: []} - const statuses = await ctx.sdk.shielded.getShieldedNullifiers(payload.nullifiers) + const statuses = await nullifierStatuses(ctx.sdk, payload.nullifiers) return {spent: statuses.filter(status => status.isSpent).map(status => status.nullifier)} } diff --git a/src/main/platform/operations/shielded/reads/nullifierStatuses.ts b/src/main/platform/operations/shielded/reads/nullifierStatuses.ts new file mode 100644 index 00000000..9408b6a6 --- /dev/null +++ b/src/main/platform/operations/shielded/reads/nullifierStatuses.ts @@ -0,0 +1,17 @@ +import {DashPlatformSDK} from 'dash-platform-sdk' +import {ShieldedNullifierStatus} from 'dash-platform-sdk/types.js' +import {PROVED_QUERY_LIMIT} from '../../../constants' + +// Drive refuses an oversized query outright rather than truncating it, so a +// wallet past one page can only be asked about a page at a time. +export async function nullifierStatuses( + sdk: DashPlatformSDK, + nullifiers: Uint8Array[], +): Promise { + const statuses: ShieldedNullifierStatus[] = [] + for (let start = 0; start < nullifiers.length; start += PROVED_QUERY_LIMIT) { + const page = nullifiers.slice(start, start + PROVED_QUERY_LIMIT) + statuses.push(...await sdk.shielded.getShieldedNullifiers(page)) + } + return statuses +} diff --git a/src/main/platform/operations/shielded/spend/buildTransition.ts b/src/main/platform/operations/shielded/spend/buildTransition.ts index 2c6e8f1a..b72b70ce 100644 --- a/src/main/platform/operations/shielded/spend/buildTransition.ts +++ b/src/main/platform/operations/shielded/spend/buildTransition.ts @@ -1,5 +1,11 @@ import {DashPlatformSDK} from 'dash-platform-sdk' -import {OrchardAddressWASM, ShieldedMemoWASM, SpendableNoteWASM, StateTransitionWASM} from 'pshenmic-dpp' +import { + OrchardAddressWASM, + ShieldedMemoWASM, + ShieldedOutputWASM, + SpendableNoteWASM, + StateTransitionWASM, +} from 'pshenmic-dpp' import {Network} from '../../../../src/types/Network' import {coreAddressToScript} from '../../../../src/utils/coreScript' import {PlatformOperations} from '../../../types/messages' @@ -17,7 +23,7 @@ export async function buildTransition( anchor: Uint8Array, changeAddress: ShieldedAddress, ): Promise { - const {seed, recipient} = payload + const {seed, recipients} = payload const amount = payload.amountCredits const base = { spends, @@ -30,17 +36,33 @@ export async function buildTransition( } switch (payload.kind) { - case 'shieldedTransfer': + case 'shieldedTransfer': { + // The bundle builder is the only one that fans out, and it carries a memo + // per output rather than one for the transition, so its arguments do not + // fit createStateTransition's flat map. + if (recipients.length > 1) { + const builder = await sdk.shielded.getShieldedBuilder() + const outputs = recipients.map(recipient => new ShieldedOutputWASM( + OrchardAddressWASM.fromBech32m(recipient.address), + recipient.amountCredits, + ShieldedMemoWASM.empty(), + )) + const {stateTransition} = await builder.shieldedTransferMulti( + spends, outputs, changeAddress, seed, COIN_TYPE[network], SHIELDED_ACCOUNT, anchor, + ) + return stateTransition + } return sdk.shielded.createStateTransition('shieldedTransfer', { ...base, - recipient: OrchardAddressWASM.fromBech32m(recipient), + recipient: OrchardAddressWASM.fromBech32m(recipients[0].address), transferAmount: amount, }) + } case 'unshield': return sdk.shielded.createStateTransition('unshield', { ...base, - outputAddress: recipient, + outputAddress: recipients[0].address, unshieldAmount: amount, }) @@ -62,7 +84,7 @@ export async function buildTransition( return sdk.shielded.createStateTransition('shieldedWithdrawal', { ...base, withdrawalAmount: amount, - outputScript: coreAddressToScript(recipient, network), + outputScript: coreAddressToScript(recipients[0].address, network), coreFeePerByte: payload.coreFeePerByte, pooling: 'Never', }) diff --git a/src/main/platform/operations/shielded/spend/fee.ts b/src/main/platform/operations/shielded/spend/fee.ts index 0b7b7bad..0450f014 100644 --- a/src/main/platform/operations/shielded/spend/fee.ts +++ b/src/main/platform/operations/shielded/spend/fee.ts @@ -6,13 +6,13 @@ import { UnshieldTransitionWASM, } from 'pshenmic-dpp' import {PoolSpendOperation} from '../../../types/messages' -import {IDENTITY_KEY_DEFINITIONS} from '../../../../src/constants/credits' -import {MIN_BUNDLE_ACTIONS} from '../constants' +import {IDENTITY_KEY_DEFINITIONS, MIN_BUNDLE_ACTIONS} from '../../../../src/constants/credits' + // What consensus will charge, from the protocol implementation itself. Never // reimplement this: it is versioned (`platformVersion`) and scales with the // action count, which is why a constant table cannot track it. -export function minimumFee(kind: PoolSpendOperation, numSpends: number): bigint { - const actions = Math.max(numSpends, MIN_BUNDLE_ACTIONS) +export function minimumFee(kind: PoolSpendOperation, actionCount: number): bigint { + const actions = Math.max(actionCount, MIN_BUNDLE_ACTIONS) switch (kind) { case 'shieldedTransfer': return ShieldedTransferTransitionWASM.computeMinimumFee(actions) diff --git a/src/main/platform/operations/shielded/spend/spend.ts b/src/main/platform/operations/shielded/spend/spend.ts index 472a511b..23268c55 100644 --- a/src/main/platform/operations/shielded/spend/spend.ts +++ b/src/main/platform/operations/shielded/spend/spend.ts @@ -1,12 +1,12 @@ import {IdentityCreateFromShieldedPoolTransitionWASM, RecoveredNoteWASM} from 'pshenmic-dpp' -import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' +import {bundleActions, maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' import {PlatformOperations} from '../../../types/messages' import {OperationContext, OperationError, throwIfAborted} from '../../types' import {consensusMessage} from '../../consensusMessage' import {buildTransition} from './buildTransition' import {checkSpent} from '../checkSpent' import {actualFee, minimumFee} from './fee' -import {MAX_SPEND_NOTES} from '../constants' +import {MAX_SPEND_NOTES, MAX_SPEND_RECIPIENTS} from '../../../../src/constants/credits' import {SHIELDED_ACCOUNT} from '../../../../src/constants/addresses' import {waitForResult} from './waitForResult' @@ -15,10 +15,13 @@ type Result = PlatformOperations['spend']['result'] export async function spend(payload: Payload, ctx: OperationContext): Promise { const {sdk, network, signal} = ctx - const {seed, kind, notes: all} = payload + const {seed, kind, notes: all, recipients} = payload const amount = payload.amountCredits if (amount <= 0n) throw new OperationError('Amount must be greater than zero', 'internal') + if (recipients.length > MAX_SPEND_RECIPIENTS) { + throw new OperationError(`A shielded spend pays at most ${MAX_SPEND_RECIPIENTS} recipients`, 'internal') + } // recoverNotes keys by array position; every index leaving this operation is // a pool index, which is what the note tables and the user's pick key on. @@ -33,7 +36,10 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise spent).map(({recoveredNote}) => poolIndex(recoveredNote)) if (stale.length > 0) ctx.notesSpent(stale) - const fee = (numSpends: number): bigint => minimumFee(kind, numSpends) + // Only a pool-to-pool transfer writes its payouts as Orchard outputs; the + // rest leave the pool, so they add no action beyond the change note. + const outputCount = kind === 'shieldedTransfer' ? recipients.length : 0 + const fee = (numSpends: number): bigint => minimumFee(kind, bundleActions(numSpends, outputCount)) const selectable = selectableNotes( checked.map(({recoveredNote, spent}) => ({index: poolIndex(recoveredNote), value: recoveredNote.note.value, spent})), payload.source, diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index 48dabb83..c9bfb229 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -199,7 +199,9 @@ export interface PlatformOperations { payload: { seed: Uint8Array kind: PoolSpendOperation - recipient: string + // A pool-to-pool transfer pays several; the two payouts pay one; creating + // an identity pays none, and funds it from amountCredits. + recipients: Recipient[] amountCredits: bigint notes: EncryptedNotePayload[] source: ShieldedSpendSource | null @@ -239,8 +241,8 @@ export interface PlatformOperations { payload: {operation: TransitionFeeOperation; params: FeeQuoteParams} result: FeeQuote } - // Every note count a spend may settle on, so the caller can resolve the fee - // and the count together without a round trip per candidate count. + // Every action count a spend may settle on, so the caller can resolve the fee, + // the note count and the recipient count together without a round trip each. spendFeeCurve: { payload: {kind: PoolSpendOperation} result: {feeCredits: bigint[]} diff --git a/src/main/src/api/shielded/startShieldedTransfer.ts b/src/main/src/api/shielded/startShieldedTransfer.ts index c48c8111..8e71489f 100644 --- a/src/main/src/api/shielded/startShieldedTransfer.ts +++ b/src/main/src/api/shielded/startShieldedTransfer.ts @@ -1,5 +1,5 @@ import { IpcMainInvokeEvent } from 'electron/utility' -import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' +import {ShieldedRecipient, ShieldedSpendSource} from '../../types/ShieldedNoteSelection' import {ShieldedService} from '../../services/platform/ShieldedService' import {ShieldedSpendState} from '../../types/Shielded' export class StartShieldedTransferHandler { @@ -9,7 +9,7 @@ export class StartShieldedTransferHandler { this.shieldedService = shieldedService } - handle = async (_event: IpcMainInvokeEvent, walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise => { - return this.shieldedService.startTransfer(walletId, password, recipient, amountCredits, source) + handle = async (_event: IpcMainInvokeEvent, walletId: string, recipients: ShieldedRecipient[], password: string, source?: ShieldedSpendSource): Promise => { + return this.shieldedService.startTransfer(walletId, password, recipients, source) } } diff --git a/src/main/src/constants/credits.ts b/src/main/src/constants/credits.ts index 19a81403..447ad22e 100644 --- a/src/main/src/constants/credits.ts +++ b/src/main/src/constants/credits.ts @@ -16,6 +16,17 @@ export const MIN_INPUT_CREDITS = 100_000n export const MIN_IDENTITY_FUNDING_CREDITS = 200_000n export const MAX_ADDRESS_INPUTS = 16 export const MAX_RECIPIENTS = 128 +// What fits the 20 KiB max_state_transition_size, not the protocol's own +// max_shielded_transition_actions of 16: the Halo2 proof grows ~2,681 bytes per +// action, so a seventh action puts the transition over the wire limit. +export const MAX_BUNDLE_ACTIONS = 6 +// The change note takes a slot of its own, and is written even at zero value. +export const MAX_SPEND_RECIPIENTS = MAX_BUNDLE_ACTIONS - 1 +// One action per note spent, so the note cap is the action cap. +export const MAX_SPEND_NOTES = MAX_BUNDLE_ACTIONS +// An Orchard bundle carries at least two actions, so a single-note spend is +// still charged for two. +export const MIN_BUNDLE_ACTIONS = 2 export const MAX_FEE_STRATEGY_STEPS = 4 export const ASSET_LOCK_PAYLOAD_VERSION = 1 diff --git a/src/main/src/services/platform/ShieldedService.ts b/src/main/src/services/platform/ShieldedService.ts index e656a4cc..9a4f22fb 100644 --- a/src/main/src/services/platform/ShieldedService.ts +++ b/src/main/src/services/platform/ShieldedService.ts @@ -13,7 +13,7 @@ import { unlockWallet, withUnlockedWallet, zeroSeed } from '../../utils/walletSe import { UnlockedWallet } from '../../types/UnlockedWallet' import { Wallet } from '../../types/Wallet' import {SHIELDED_ADDRESS_WINDOW} from '../../constants/addresses' -import {SHIELDED_NOTES_FETCH_BATCH, SHIELD_FUNDING_FEE_RESERVE_CREDITS} from '../../constants/credits' +import {MAX_SPEND_NOTES, SHIELDED_NOTES_FETCH_BATCH, SHIELD_FUNDING_FEE_RESERVE_CREDITS} from '../../constants/credits' import { findNextIdentityIndex, identityPath } from '../../utils/identityKeys' import {coreFeePerByte} from '../../utils/coreFeeRate' import {platformAccountXpub, platformAddressDeriver} from '../../utils/platformAddress' @@ -34,8 +34,14 @@ import { ShieldedSyncState, } from '../../types/Shielded' import {OperationFee} from '../../types/Fee' -import {ShieldedSpendSource} from '../../types/ShieldedNoteSelection' -import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../utils/shieldedNoteSelection' +import {ShieldedRecipient, ShieldedSpendSource} from '../../types/ShieldedNoteSelection' +import { + bundleActions, + maxSpendableCredits, + requireShieldedRecipients, + selectableNotes, + selectSpendNotes, +} from '../../utils/shieldedNoteSelection' import {requireWallet} from '../../utils/requireWallet' import { EncryptedNotePayload, @@ -429,16 +435,18 @@ export class ShieldedService { return this.spendStates.get(walletId) ?? this.idleSpendState() } - startTransfer(walletId: string, password: string, recipient: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { - return this.startSpend(walletId, password, 'shieldedTransfer', recipient, amountCredits, source) + // One bundle pays several Orchard addresses out of one note set, in one proof + // and for one fee, which grows with the outputs rather than repeating. + startTransfer(walletId: string, password: string, recipients: ShieldedRecipient[], source?: ShieldedSpendSource | null): Promise { + return this.startSpend(walletId, password, 'shieldedTransfer', recipients, source) } startUnshield(walletId: string, password: string, outputAddress: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { - return this.startSpend(walletId, password, 'unshield', outputAddress, amountCredits, source) + return this.startSpend(walletId, password, 'unshield', [{address: outputAddress, amountCredits}], source) } startWithdrawal(walletId: string, password: string, coreAddress: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { - return this.startSpend(walletId, password, 'shieldedWithdrawal', coreAddress, amountCredits, source) + return this.startSpend(walletId, password, 'shieldedWithdrawal', [{address: coreAddress, amountCredits}], source) } // Returns the in-flight state when a spend is already running for this @@ -453,13 +461,13 @@ export class ShieldedService { return {state, running: false} } - private async startSpend(walletId: string, password: string, kind: PoolSpendOperation, recipient: string, amountCredits: bigint, source?: ShieldedSpendSource | null): Promise { + private async startSpend(walletId: string, password: string, kind: PoolSpendOperation, recipients: ShieldedRecipient[], source?: ShieldedSpendSource | null): Promise { const {state, running} = this.beginSpend(walletId) if (running) return state let unlocked: UnlockedWallet | null = null try { - if (amountCredits <= 0n) throw new Error('Amount must be greater than zero') + const amountCredits = requireShieldedRecipients(recipients) unlocked = await unlockWallet(this.walletDAO, walletId, password) const {wallet: {network}, seed} = unlocked @@ -471,7 +479,7 @@ export class ShieldedService { this.runSpend(walletId, network, state, { seed, kind, - recipient, + recipients, amountCredits, notes, source: source ?? null, @@ -540,7 +548,7 @@ export class ShieldedService { this.runSpend(walletId, network, state, { seed, kind: 'identityCreateFromShielded', - recipient: '', + recipients: [], amountCredits: denominationCredits, notes, source: null, @@ -642,10 +650,14 @@ export class ShieldedService { kind: PoolSpendOperation, amountCredits: bigint, source: ShieldedSpendSource | null, + outputCount = 1, ): Promise { const wallet = await requireWallet(this.walletDAO, walletId) const curve = await this.spendFeeCurve(wallet.network, kind) - const feeForCount = (numSpends: number): bigint => curve[Math.min(numSpends, curve.length) - 1] + // Only a pool-to-pool transfer writes its payouts as Orchard outputs. + const outputs = kind === 'shieldedTransfer' ? outputCount : 0 + const feeForCount = (numSpends: number): bigint => + curve[Math.min(bundleActions(numSpends, outputs), curve.length) - 1] const candidates = selectableNotes( (this.syncStates.get(walletId)?.notes ?? []) @@ -654,15 +666,15 @@ export class ShieldedService { ) const selection = amountCredits > 0n - ? selectSpendNotes(candidates, amountCredits, curve.length, feeForCount, source) + ? selectSpendNotes(candidates, amountCredits, MAX_SPEND_NOTES, feeForCount, source) : null return { feeCredits: selection?.feeCredits ?? feeForCount(1), feeDuffs: null, maxDuffs: null, - maxPerTx: maxSpendableCredits(candidates, curve.length, feeForCount, source), - noteLimit: curve.length, + maxPerTx: maxSpendableCredits(candidates, MAX_SPEND_NOTES, feeForCount, source), + noteLimit: MAX_SPEND_NOTES, } } diff --git a/src/main/src/services/wallet/FeeService.ts b/src/main/src/services/wallet/FeeService.ts index 5ed04213..92b44fe5 100644 --- a/src/main/src/services/wallet/FeeService.ts +++ b/src/main/src/services/wallet/FeeService.ts @@ -101,10 +101,13 @@ export class FeeService { case 'shieldedTransfer': case 'unshield': case 'shieldedWithdrawal': - case 'identityCreateFromShielded': + case 'identityCreateFromShielded': { requireAutomaticSelection(params.coreSource) requireAutomaticInputs(params.platformSource) - return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.shieldedSource ?? null) + const outputCount = Array.isArray(params.recipient) ? Math.max(params.recipient.length, 1) : 1 + return this.shielded.estimateSpendFee( + walletId, operation, params.amountCredits, params.shieldedSource ?? null, outputCount) + } // Funded by platform addresses: the fee scales with the inputs, so the // selection has to run before the price is known. diff --git a/src/main/src/types/ShieldedNoteSelection.ts b/src/main/src/types/ShieldedNoteSelection.ts index 6b1a15a7..7f020bd7 100644 --- a/src/main/src/types/ShieldedNoteSelection.ts +++ b/src/main/src/types/ShieldedNoteSelection.ts @@ -17,6 +17,13 @@ export type ShieldedSpendSource = | {kind: 'address'; noteIndexes: number[]} | {kind: 'notes'; noteIndexes: number[]} +// One Orchard output of a pool spend. Diversified addresses mean a repeat is +// not detectable as one, so the same address twice is two notes. +export interface ShieldedRecipient { + address: string + amountCredits: bigint +} + export interface NoteSelectionResult { selected: SelectableNote[] total: bigint diff --git a/src/main/src/utils/shieldedNoteSelection.ts b/src/main/src/utils/shieldedNoteSelection.ts index 90acf552..694db697 100644 --- a/src/main/src/utils/shieldedNoteSelection.ts +++ b/src/main/src/utils/shieldedNoteSelection.ts @@ -2,9 +2,31 @@ import { NoteSelectionResult, OwnedNote, SelectableNote, + ShieldedRecipient, ShieldedSpendSource, SpendFeeForCount, } from '../types/ShieldedNoteSelection' +import {MAX_SPEND_RECIPIENTS, MIN_BUNDLE_ACTIONS} from '../constants/credits' + +// One action per note spent, one per shielded output, and the change note takes +// a slot even at zero value. +export function bundleActions(numSpends: number, numOutputs: number): number { + return Math.max(numSpends, numOutputs + 1, MIN_BUNDLE_ACTIONS) +} + +// Every note a bundle pays out to, and the amount it has to fund. Each output +// is another Orchard action, which is what the recipient cap counts. +export function requireShieldedRecipients(recipients: ShieldedRecipient[]): bigint { + if (recipients.length === 0 || recipients.length > MAX_SPEND_RECIPIENTS) { + throw new Error(`Recipient count must be between 1 and ${MAX_SPEND_RECIPIENTS}`) + } + for (const recipient of recipients) { + if (recipient.amountCredits <= 0n) { + throw new Error('Every recipient must be paid more than zero credits') + } + } + return recipients.reduce((sum, recipient) => sum + recipient.amountCredits, 0n) +} function byValueDesc(a: SelectableNote, b: SelectableNote): number { if (a.value !== b.value) return a.value > b.value ? -1 : 1 diff --git a/src/preload/definitions.ts b/src/preload/definitions.ts index 3968458e..402e3e33 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -18,6 +18,10 @@ type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// Mirrors the ShieldedRecipient in the same file: one bundle pays several +// Orchard addresses, each its own amount. +type ShieldedRecipient = { address: string; amountCredits: bigint } + // Mirrors src/main/src/types/PlatformTransfer: one address to draw from, or // every address it may draw on, how much of each, and which one is charged. type PlatformPickedInput = { address: string; credits: bigint } @@ -103,7 +107,7 @@ export const apiDefinitions = (ipcRenderer) => ({ startShieldedSync: (walletId: string, password: string) => ipcRenderer.invoke('startShieldedSync', walletId, password), getShieldedSyncState: (walletId: string) => ipcRenderer.invoke('getShieldedSyncState', walletId), refreshShieldedSpentNotes: (walletId: string) => ipcRenderer.invoke('refreshShieldedSpentNotes', walletId), - startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedTransfer', walletId, recipient, amountCredits, password, source), + startShieldedTransfer: (walletId: string, recipients: ShieldedRecipient[], password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedTransfer', walletId, recipients, password, source), startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedUnshield', walletId, outputAddress, amountCredits, password, source), startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => ipcRenderer.invoke('startShieldedWithdrawal', walletId, coreAddress, amountCredits, password, source), startShieldedIdentityCreate: (walletId: string, denominationCredits: bigint, password: string) => ipcRenderer.invoke('startShieldedIdentityCreate', walletId, denominationCredits, password), diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index c2749267..8005e722 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -20,6 +20,10 @@ type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// Mirrors the ShieldedRecipient in the same file: one bundle pays several +// Orchard addresses, each its own amount. +type ShieldedRecipient = { address: string; amountCredits: bigint } + // Mirrors src/main/src/types/PlatformTransfer: one address to draw from, or // every address it may draw on, how much of each, and which one is charged. type PlatformPickedInput = { address: string; credits: bigint } @@ -155,7 +159,7 @@ declare global { startShieldedSync: (walletId: string, password: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> getShieldedSyncState: (walletId: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> refreshShieldedSpentNotes: (walletId: string) => Promise<{ phase: 'idle' | 'syncing' | 'recovering' | 'done' | 'error'; fetched: number; total: number; balance: bigint | null; notes: { index: number; amount: bigint; spent: boolean }[]; error: string | null; syncedAt: number | null }> - startShieldedTransfer: (walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> + startShieldedTransfer: (walletId: string, recipients: ShieldedRecipient[], password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> startShieldedUnshield: (walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> startShieldedWithdrawal: (walletId: string, coreAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; error: string | null }> startShieldedIdentityCreate: (walletId: string, denominationCredits: bigint, password: string) => Promise<{ phase: 'idle' | 'syncing' | 'proving' | 'broadcasting' | 'done' | 'error'; fetched: number; total: number; stHash: string | null; identityId: string | null; error: string | null }> diff --git a/src/renderer/src/api/index.ts b/src/renderer/src/api/index.ts index 5a1709ba..c8f06b77 100644 --- a/src/renderer/src/api/index.ts +++ b/src/renderer/src/api/index.ts @@ -1,6 +1,6 @@ import { WalletTxDto } from '@renderer/types/WalletTransaction' import { TransferOperation } from '../enums/TransferOperation' -import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreRecipient, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformRecipient, PlatformSendResult, PlatformSpendSource, PreferencesJSON, SelectableUtxo, SendResult, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, CoreRecipient, CoreSpendSource, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PlatformAddressDto, PlatformRecipient, PlatformSendResult, PlatformSpendSource, PreferencesJSON, SelectableUtxo, SendResult, ShieldedRecipient, ShieldedSpendSource, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' export class API { private static get api() { @@ -243,8 +243,8 @@ export class API { return this.api.shieldToPool(walletId, fromAddress, toAddress, amountCredits, password) as Promise } - static async startShieldedTransfer(walletId: string, recipient: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise { - return this.api.startShieldedTransfer(walletId, recipient, amountCredits, password, source) as Promise + static async startShieldedTransfer(walletId: string, recipients: ShieldedRecipient[], password: string, source?: ShieldedSpendSource): Promise { + return this.api.startShieldedTransfer(walletId, recipients, password, source) as Promise } static async startShieldedUnshield(walletId: string, outputAddress: string, amountCredits: bigint, password: string, source?: ShieldedSpendSource): Promise { diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index 0c2c3cca..eadd71aa 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -71,6 +71,13 @@ export type ShieldedSpendSource = | { kind: 'address'; noteIndexes: number[] } | { kind: 'notes'; noteIndexes: number[] } +// One Orchard output of a pool spend. Diversified addresses mean a repeat is +// not detectable as one, so the same address twice is two notes. +export interface ShieldedRecipient { + address: string + amountCredits: bigint +} + // One transition can pay many addresses, each its own amount. export interface PlatformRecipient { address: string diff --git a/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx b/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx new file mode 100644 index 00000000..d00faa1e --- /dev/null +++ b/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx @@ -0,0 +1,88 @@ +import { Text, ShieldSmallIcon } from "@renderer/components/dash-ui-kit-enxtended"; +import Checkbox from "@renderer/components/ui/Checkbox"; +import CreditsAmount from "@renderer/components/ui/CreditsAmount"; +import { ShieldedNoteInfo } from "@renderer/api/types"; + +// TEST ONLY, to be reverted. +interface ShieldedNotePickerProps { + notes: ShieldedNoteInfo[] + picked: number[] + onToggle: (index: number, checked: boolean) => void + onClear: () => void + maxNotes: number +} + +export default function ShieldedNotePicker({notes, picked, onToggle, onClear, maxNotes}: ShieldedNotePickerProps): React.JSX.Element { + const chosen = new Set(picked) + const full = picked.length >= maxNotes + const total = notes + .filter(note => chosen.has(note.index)) + .reduce((sum, note) => sum + note.amount, 0n) + + return ( +
+
+ + Picked {picked.length}/{maxNotes} notes (test) + +
+ + + + {picked.length > 0 && ( + + )} +
+
+ +
+ {notes.length === 0 && ( + + No spendable notes — sync on the Shielded page + + )} + {notes.map(note => { + const isPicked = chosen.has(note.index) + + return ( +
+ (isPicked || !full) && onToggle(note.index, next)} + label={ +
+ +
+ + note #{note.index} + + + · {note.address.slice(0, 14)}… + +
+
+ } + /> +
+ ) + })} +
+ + + Picked notes are spent whole and override the address above. A bundle + fits {maxNotes} actions, and every note spent takes one of them. + +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/ShieldedRecipientsTest.tsx b/src/renderer/src/components/pages/transfer/ShieldedRecipientsTest.tsx new file mode 100644 index 00000000..2cb8a919 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/ShieldedRecipientsTest.tsx @@ -0,0 +1,65 @@ +import { Text } from "@renderer/components/dash-ui-kit-enxtended"; + +// TEST ONLY, to be reverted. It names addresses alone: the amount stays the one +// typed on the amount step, so the rest of the send flow is untouched. +interface ShieldedRecipientsTestProps { + addresses: string[] + onChange: (addresses: string[]) => void + maxRecipients: number +} + +export default function ShieldedRecipientsTest({addresses, onChange, maxRecipients}: ShieldedRecipientsTestProps): React.JSX.Element { + return ( +
+
+ + Extra recipients {addresses.length}/{maxRecipients} (test) + +
+ + {addresses.length > 0 && ( + + )} +
+
+ + {addresses.map((address, index) => ( +
+ onChange(addresses.map((entry, i) => (i === index ? e.target.value : entry)))} + placeholder={"shielded address"} + className={"flex-1 min-w-0 bg-transparent outline-none dash-text-default placeholder:opacity-30 text-[.8125rem] font-mono"} + /> + +
+ ))} + + {addresses.length > 0 && ( + + The amount you type is split evenly between the recipient above and + these, with any remainder going to the first. + + )} +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index dd25cbf0..ffc8df82 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -9,9 +9,12 @@ import Checkbox from "@renderer/components/ui/Checkbox"; import PlatformInputPicker from "./PlatformInputPicker"; import PlatformRecipientsTest from "./PlatformRecipientsTest"; import CoreRecipientsTest from "./CoreRecipientsTest"; +import ShieldedRecipientsTest from "./ShieldedRecipientsTest"; +import ShieldedNotePicker from "./ShieldedNotePicker"; import CoreUtxoPicker, { outpointKey } from "./CoreUtxoPicker"; import { PLATFORM_INPUT_LIMIT, PLATFORM_RECIPIENT_LIMIT } from "@renderer/constants/platform"; import { CORE_RECIPIENT_LIMIT } from "@renderer/constants/core"; +import { SHIELDED_NOTE_LIMIT, SHIELDED_RECIPIENT_LIMIT } from "@renderer/constants/shielded"; import ProverPill from "@renderer/components/pages/shielded/ProverPill"; import Spinner from "@renderer/components/ui/Spinner"; import { useAuth } from "@renderer/contexts/AuthContext"; @@ -109,6 +112,8 @@ function WalletTransferHub(): React.JSX.Element { })) const [testRecipients, setTestRecipients] = useState([]) const [testCoreRecipients, setTestCoreRecipients] = useState([]) + const [testShieldedRecipients, setTestShieldedRecipients] = useState([]) + const [pickedNoteIndexes, setPickedNoteIndexes] = useState([]) const [utxos, setUtxos] = useState([]) const [pickedOutpoints, setPickedOutpoints] = useState([]) const [pickedPlatformInputs, setPickedPlatformInputs] = useState([]) @@ -240,17 +245,28 @@ function WalletTransferHub(): React.JSX.Element { const selectedShieldedAddress = shieldedFromAddress != null && shieldedAddresses.includes(shieldedFromAddress) ? shieldedFromAddress : shieldedAddresses[0] + const shieldedPicking = shieldedSpendOperation && useSpecificSource + const pickedNotes = useMemo( + () => (shieldedPicking ? spendableNotes.filter(n => pickedNoteIndexes.includes(n.index)) : []), + [shieldedPicking, spendableNotes, pickedNoteIndexes], + ) const shieldedSpecificNotes = useMemo( - () => shieldedSpendOperation && useSpecificSource && selectedShieldedAddress != null - ? spendableNotes.filter(n => n.address === selectedShieldedAddress) - : undefined, - [shieldedSpendOperation, useSpecificSource, selectedShieldedAddress, spendableNotes], + () => pickedNotes.length > 0 ? pickedNotes + : shieldedPicking && selectedShieldedAddress != null + ? spendableNotes.filter(n => n.address === selectedShieldedAddress) + : undefined, + [pickedNotes, shieldedPicking, selectedShieldedAddress, spendableNotes], ) + // A pick names the notes themselves, which is the only way a spend reaches + // for ones an amount would have stopped short of. const shieldedSpendSource = useMemo( (): ShieldedSpendSource | undefined => shieldedSpecificNotes == null ? undefined - : {kind: 'address', noteIndexes: shieldedSpecificNotes.map(note => note.index)}, - [shieldedSpecificNotes], + : { + kind: pickedNotes.length > 0 ? 'notes' : 'address', + noteIndexes: shieldedSpecificNotes.map(note => note.index), + }, + [shieldedSpecificNotes, pickedNotes], ) const platformPicking = specificSourceKind === SourceKind.PlatformAddress && useSpecificSource @@ -342,7 +358,24 @@ function WalletTransferHub(): React.JSX.Element { [trimmedTo, testCoreRecipients, amountDuffs], ) - const destinationValid = manyRecipients + // TEST ONLY. One bundle pays them all, so the split is the same as elsewhere. + const manyShieldedRecipients = operation === TransferOperation.ShieldedTransfer && testShieldedRecipients.length > 0 + const shieldedRecipientList = useMemo( + () => { + const addresses = [trimmedTo, ...testShieldedRecipients.map(entry => entry.trim())].filter(entry => entry.length > 0) + const share = addresses.length === 0 ? 0n : amountCredits / BigInt(addresses.length) + return addresses.map((address, index) => ({ + address, + amountCredits: index === 0 ? amountCredits - share * BigInt(addresses.length - 1) : share, + })) + }, + [trimmedTo, testShieldedRecipients, amountCredits], + ) + + const destinationValid = manyShieldedRecipients + ? shieldedRecipientList.length === testShieldedRecipients.length + 1 + && shieldedRecipientList.every(entry => isLikelyShieldedAddress(entry.address)) + : manyRecipients ? recipientList.length === testRecipients.length + 1 && recipientList.every(entry => isValidPlatformAddress(entry.address, network ?? undefined)) : manyCoreRecipients @@ -358,6 +391,7 @@ function WalletTransferHub(): React.JSX.Element { destinationValid, recipient: manyRecipients ? recipientList.map(entry => entry.address) : manyCoreRecipients ? coreRecipientList.map(entry => entry.address) + : manyShieldedRecipients ? shieldedRecipientList.map(entry => entry.address) : trimmedTo, amountCredits, amountDuffs: isCoreOperation ? amountDuffs : null, @@ -486,6 +520,8 @@ function WalletTransferHub(): React.JSX.Element { setPlatformFeeAddress(null) setTestRecipients([]) setTestCoreRecipients([]) + setTestShieldedRecipients([]) + setPickedNoteIndexes([]) setPickedOutpoints([]) const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false } draftRef.current = resetDraft @@ -570,6 +606,13 @@ function WalletTransferHub(): React.JSX.Element { maxInputs={PLATFORM_INPUT_LIMIT} /> )} + {operation === TransferOperation.ShieldedTransfer && ( + + )} {useSpecificSource && shieldedSpendOperation && ( <> setSpecificSourcePreferences(current => updateSpecificSourceAddress(current, SourceKind.Shielded, address))} /> + setPickedNoteIndexes(current => + checked ? [...current, index] : current.filter(entry => entry !== index))} + onClear={() => setPickedNoteIndexes([])} + maxNotes={SHIELDED_NOTE_LIMIT} + /> setNotesUnlockOpen(true)} syncing={notesSyncing} /> )} @@ -841,7 +892,14 @@ function WalletTransferHub(): React.JSX.Element { if (!walletId) { return Promise.resolve({ phase: ShieldedSpendPhase.Error, fetched: 0, total: 0, stHash: null, identityId: null, error: 'No wallet selected' }) } - if (operation === TransferOperation.ShieldedTransfer) return API.startShieldedTransfer(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) + if (operation === TransferOperation.ShieldedTransfer) { + return API.startShieldedTransfer( + walletId, + manyShieldedRecipients ? shieldedRecipientList : [{ address: trimmedTo, amountCredits }], + password, + shieldedSpendSource, + ) + } if (operation === TransferOperation.Unshield) return API.startShieldedUnshield(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) if (operation === TransferOperation.IdentityCreateFromShielded) return API.startShieldedIdentityCreate(walletId, amountCredits, password) return API.startShieldedWithdrawal(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) diff --git a/src/renderer/src/constants/shielded.ts b/src/renderer/src/constants/shielded.ts index 1548cb95..1acda5d5 100644 --- a/src/renderer/src/constants/shielded.ts +++ b/src/renderer/src/constants/shielded.ts @@ -9,3 +9,10 @@ export const SHIELDED_SPEND_POLL_MS = 700 export const SHIELDED_BALANCE_UNKNOWN_TOOLTIP = 'Sync shielded notes to load this balance.' export const SHIELDED_SPEND_RETRY_MS = 1_000 + +// A bundle fits 6 Orchard actions under the 20 KiB transition limit, and the +// change note takes one of them. +export const SHIELDED_RECIPIENT_LIMIT = 5 + +// One Orchard action per note spent, so the note cap is the bundle's. +export const SHIELDED_NOTE_LIMIT = 6 diff --git a/tests/unit/estimateFee.test.ts b/tests/unit/estimateFee.test.ts index fd000f9c..93ff8684 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -164,11 +164,23 @@ describe('estimateFee', () => { const {service: svc, estimateSpendFee} = service() const source: ShieldedSpendSource = {kind: 'address', noteIndexes: [2, 5]} const fee = await svc.estimateFee(WALLET, operation, params({shieldedSource: source})) - expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, operation, 1_000_000n, source) + expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, operation, 1_000_000n, source, 1) expect(fee).toEqual({feeCredits: 7n, feeDuffs: null, maxDuffs: null, maxPerTx: 90n, noteLimit: 6}) } }) + // The fan-out lands on the action count, which is the only thing the shielded + // fee follows, so a quote that ignored it would underprice every bundle. + it('tells the shielded service how many addresses a transfer pays', async () => { + const {service: svc, estimateSpendFee} = service() + + await svc.estimateFee(WALLET, 'shieldedTransfer', params({ + recipient: ['addr-a', 'addr-b', 'addr-c'], + })) + + expect(estimateSpendFee).toHaveBeenCalledWith(WALLET, 'shieldedTransfer', 1_000_000n, null, 3) + }) + // The L1 fee used to bypass this method and ride the status poll instead. it('prices a Core send in duffs, from the same method', async () => { const {service: svc, request} = service([], [utxo(ONE_DASH, 1)]) diff --git a/tests/unit/shieldedNoteSelection.test.ts b/tests/unit/shieldedNoteSelection.test.ts index e3d297d2..23780fee 100644 --- a/tests/unit/shieldedNoteSelection.test.ts +++ b/tests/unit/shieldedNoteSelection.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest' -import {maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../src/main/src/utils/shieldedNoteSelection' +import { + bundleActions, + maxSpendableCredits, + requireShieldedRecipients, + selectableNotes, + selectSpendNotes, +} from '../../src/main/src/utils/shieldedNoteSelection' import {OwnedNote, SelectableNote, ShieldedSpendSource} from '../../src/main/src/types/ShieldedNoteSelection' +import {MAX_BUNDLE_ACTIONS, MAX_SPEND_RECIPIENTS, MIN_BUNDLE_ACTIONS} from '../../src/main/src/constants/credits' function note(index: number, value: bigint): SelectableNote { return { index, value } } @@ -188,3 +195,79 @@ describe('the most a picked set can spend', () => { expect(maxSpendableCredits([note(0, 5n)], 6, () => 10n, picked(0))).toBe(0n) }) }) + +describe('the actions a bundle is charged for', () => { + // Spends and outputs occupy the same actions rather than adding up, so a + // one-note payment to three addresses is priced by the outputs. + it('takes whichever of the notes and the outputs needs more slots', () => { + expect(bundleActions(1, 3)).toBe(4) + expect(bundleActions(6, 3)).toBe(6) + }) + + // The change note is written even at zero value, which is what fixes the fee + // before the change amount is known. + it('reserves a slot for the change note', () => { + expect(bundleActions(1, 1)).toBe(MIN_BUNDLE_ACTIONS) + expect(bundleActions(1, 5)).toBe(6) + }) + + // The 20 KiB transition limit binds before the protocol's own 16-action cap, + // and a bundle over it is rejected after the seconds its proof cost. + it('stays under the size a transition is allowed to reach', () => { + const FIXED_BYTES = 2_930 + const BYTES_PER_ACTION = 2_681 + const MAX_TRANSITION_BYTES = 20_480 + const wireSize = (actions: number): number => FIXED_BYTES + BYTES_PER_ACTION * actions + + expect(wireSize(MAX_BUNDLE_ACTIONS)).toBeLessThan(MAX_TRANSITION_BYTES) + expect(wireSize(MAX_BUNDLE_ACTIONS + 1)).toBeGreaterThan(MAX_TRANSITION_BYTES) + }) + + it('never falls below the bundle minimum', () => { + expect(bundleActions(1, 0)).toBe(MIN_BUNDLE_ACTIONS) + expect(bundleActions(0, 0)).toBe(MIN_BUNDLE_ACTIONS) + }) + + // A payout leaves the pool rather than becoming an Orchard output, so it + // costs no action of its own. + it('charges a payout nothing beyond its change note', () => { + expect(bundleActions(3, 0)).toBe(3) + }) +}) + +describe('the recipients a bundle is allowed to pay', () => { + const recipient = (address: string, amountCredits: bigint): {address: string; amountCredits: bigint} => + ({address, amountCredits}) + + it('funds the sum of every output', () => { + expect(requireShieldedRecipients([ + recipient('addr-a', 1_000n), + recipient('addr-b', 2_500n), + ])).toBe(3_500n) + }) + + it('refuses a spend with nothing to pay', () => { + expect(() => requireShieldedRecipients([])).toThrow(/between 1 and/) + }) + + // Over the cap the builder throws before proving; refusing here keeps the + // seconds a proof costs off a bundle consensus would reject anyway. + it('refuses more recipients than the action cap leaves room for', () => { + const many = Array.from({length: MAX_SPEND_RECIPIENTS + 1}, () => recipient('addr-a', 1_000n)) + expect(() => requireShieldedRecipients(many)).toThrow(/between 1 and/) + expect(bundleActions(1, MAX_SPEND_RECIPIENTS)).toBe(MAX_SPEND_RECIPIENTS + 1) + }) + + it('refuses an output paid nothing', () => { + expect(() => requireShieldedRecipients([recipient('addr-a', 0n)])) + .toThrow(/more than zero/) + }) + + // Diversified addresses make a repeat undetectable as one, so nothing merges. + it('lets one address be paid twice', () => { + expect(requireShieldedRecipients([ + recipient('addr-a', 1_000n), + recipient('addr-a', 1_000n), + ])).toBe(2_000n) + }) +}) diff --git a/tests/unit/shieldedSeedZeroing.test.ts b/tests/unit/shieldedSeedZeroing.test.ts index 016dc29d..3c68577a 100644 --- a/tests/unit/shieldedSeedZeroing.test.ts +++ b/tests/unit/shieldedSeedZeroing.test.ts @@ -125,7 +125,7 @@ describe('the seed a shielded job holds', () => { it('is zeroed when a spend settles', async () => { const {service} = wire() - await service.startTransfer(WALLET, PASSWORD, 'recipient', 100n) + await service.startTransfer(WALLET, PASSWORD, [{address: 'recipient', amountCredits: 100n}]) await vi.waitFor(() => expect(zeroed()).toBe(true)) }) @@ -133,7 +133,7 @@ describe('the seed a shielded job holds', () => { const {service, request} = wire() request.mockRejectedValue(new Error('prover died')) - await service.startTransfer(WALLET, PASSWORD, 'recipient', 100n) + await service.startTransfer(WALLET, PASSWORD, [{address: 'recipient', amountCredits: 100n}]) await vi.waitFor(() => expect(zeroed()).toBe(true)) }) @@ -143,7 +143,7 @@ describe('the seed a shielded job holds', () => { const {service, poolDAO} = wire() poolDAO.getAllEncryptedNotes.mockRejectedValue(new Error('database is locked')) - await service.startTransfer(WALLET, PASSWORD, 'recipient', 100n) + await service.startTransfer(WALLET, PASSWORD, [{address: 'recipient', amountCredits: 100n}]) expect(zeroed()).toBe(true) }) @@ -173,7 +173,7 @@ describe('the seed a shielded job holds', () => { return {stHash: 'st', identityId: null} }) - await service.startTransfer(WALLET, PASSWORD, 'recipient', 100n) + await service.startTransfer(WALLET, PASSWORD, [{address: 'recipient', amountCredits: 100n}]) await vi.waitFor(() => expect(zeroed()).toBe(true)) expect(liveDuringSync).toBe(true) @@ -190,7 +190,7 @@ describe('the seed a shielded job holds', () => { return {stHash: 'st', identityId: null} }) - await service.startTransfer(WALLET, PASSWORD, 'recipient', 100n) + await service.startTransfer(WALLET, PASSWORD, [{address: 'recipient', amountCredits: 100n}]) await vi.waitFor(() => expect(zeroed()).toBe(true)) expect(liveDuringSpend).toBe(true) From fb4711f9d82716dd7089670091c503468663232a Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 16:28:17 +0300 Subject: [PATCH 11/31] tiny fixes for shielded coin control --- .../shielded/spend/buildTransition.ts | 9 +- .../operations/shielded/spend/spend.ts | 14 +- src/main/platform/types/messages.ts | 4 +- tests/unit/shieldedBuildTransition.test.ts | 184 ++++++++++++++++++ 4 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 tests/unit/shieldedBuildTransition.test.ts diff --git a/src/main/platform/operations/shielded/spend/buildTransition.ts b/src/main/platform/operations/shielded/spend/buildTransition.ts index b72b70ce..49f5d021 100644 --- a/src/main/platform/operations/shielded/spend/buildTransition.ts +++ b/src/main/platform/operations/shielded/spend/buildTransition.ts @@ -24,7 +24,6 @@ export async function buildTransition( changeAddress: ShieldedAddress, ): Promise { const {seed, recipients} = payload - const amount = payload.amountCredits const base = { spends, changeAddress, @@ -55,7 +54,7 @@ export async function buildTransition( return sdk.shielded.createStateTransition('shieldedTransfer', { ...base, recipient: OrchardAddressWASM.fromBech32m(recipients[0].address), - transferAmount: amount, + transferAmount: recipients[0].amountCredits, }) } @@ -63,7 +62,7 @@ export async function buildTransition( return sdk.shielded.createStateTransition('unshield', { ...base, outputAddress: recipients[0].address, - unshieldAmount: amount, + unshieldAmount: recipients[0].amountCredits, }) case 'identityCreateFromShielded': { @@ -75,7 +74,7 @@ export async function buildTransition( ...base, publicKeys: keys.publicKeys, privateKeys: keys.privateKeys, - denomination: amount, + denomination: payload.amountCredits, sendToAddressOnCreationFailure: payload.failureAddress, }) } @@ -83,7 +82,7 @@ export async function buildTransition( case 'shieldedWithdrawal': return sdk.shielded.createStateTransition('shieldedWithdrawal', { ...base, - withdrawalAmount: amount, + withdrawalAmount: recipients[0].amountCredits, outputScript: coreAddressToScript(recipients[0].address, network), coreFeePerByte: payload.coreFeePerByte, pooling: 'Never', diff --git a/src/main/platform/operations/shielded/spend/spend.ts b/src/main/platform/operations/shielded/spend/spend.ts index 23268c55..621670a2 100644 --- a/src/main/platform/operations/shielded/spend/spend.ts +++ b/src/main/platform/operations/shielded/spend/spend.ts @@ -19,8 +19,18 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise MAX_SPEND_RECIPIENTS) { - throw new OperationError(`A shielded spend pays at most ${MAX_SPEND_RECIPIENTS} recipients`, 'internal') + + // Only a transfer fans out; the two payouts name exactly one address and an + // identity create names none, so the count is decided by the kind rather than + // bounded from above. Every builder below indexes what this admits. + const allowedRecipients = kind === 'shieldedTransfer' ? MAX_SPEND_RECIPIENTS + : kind === 'identityCreateFromShielded' ? 0 + : 1 + if (recipients.length > allowedRecipients || recipients.length < Math.min(allowedRecipients, 1)) { + throw new OperationError( + `${kind} takes ${allowedRecipients} recipients at most, and was given ${recipients.length}`, + 'internal', + ) } // recoverNotes keys by array position; every index leaving this operation is diff --git a/src/main/platform/types/messages.ts b/src/main/platform/types/messages.ts index c9bfb229..41107959 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -200,8 +200,10 @@ export interface PlatformOperations { seed: Uint8Array kind: PoolSpendOperation // A pool-to-pool transfer pays several; the two payouts pay one; creating - // an identity pays none, and funds it from amountCredits. + // an identity pays none. Every payout amount is read from its own entry. recipients: Recipient[] + // What leaves the pool, which is what the note selection has to cover: the + // sum of the recipients, or the denomination when there are none. amountCredits: bigint notes: EncryptedNotePayload[] source: ShieldedSpendSource | null diff --git a/tests/unit/shieldedBuildTransition.test.ts b/tests/unit/shieldedBuildTransition.test.ts new file mode 100644 index 00000000..54c2a9d4 --- /dev/null +++ b/tests/unit/shieldedBuildTransition.test.ts @@ -0,0 +1,184 @@ +import {describe, it, expect, vi} from 'vitest' + +vi.mock('pshenmic-dpp', () => ({ + OrchardAddressWASM: {fromBech32m: (address: string) => ({address})}, + ShieldedMemoWASM: {empty: () => ({memo: 'empty'})}, + ShieldedOutputWASM: class { + constructor(public address: {address: string}, public amount: bigint, public memo: unknown) {} + }, +})) + +// Key derivation is the identity path's own concern, and needs a real seed. +vi.mock('../../src/main/platform/operations/shielded/spend/identityKeys', () => ({ + identityKeys: () => ({publicKeys: [], privateKeys: []}), +})) + +import {DashPlatformSDK} from 'dash-platform-sdk' +import {buildTransition} from '../../src/main/platform/operations/shielded/spend/buildTransition' +import {spend} from '../../src/main/platform/operations/shielded/spend/spend' +import {OperationContext} from '../../src/main/platform/operations/types' +import {PlatformOperations} from '../../src/main/platform/types/messages' +import {MAX_SPEND_RECIPIENTS} from '../../src/main/src/constants/credits' + +type Payload = PlatformOperations['spend']['payload'] + +const ANCHOR = new Uint8Array(32).fill(1) +const SEED = new Uint8Array(64).fill(7) +const CHANGE = {address: 'change'} as never + +function sdkStub(): { + sdk: DashPlatformSDK + createStateTransition: ReturnType + shieldedTransferMulti: ReturnType +} { + const createStateTransition = vi.fn(async () => 'single-transition') + const shieldedTransferMulti = vi.fn(async () => ({stateTransition: 'multi-transition', fee: 6n})) + const sdk = { + shielded: { + createStateTransition, + getShieldedBuilder: async () => ({shieldedTransferMulti}), + }, + } as unknown as DashPlatformSDK + return {sdk, createStateTransition, shieldedTransferMulti} +} + +const payload = (overrides: Partial): Payload => ({ + seed: SEED, + kind: 'shieldedTransfer', + recipients: [{address: 'addr-a', amountCredits: 1_000n}], + amountCredits: 1_000n, + notes: [], + source: null, + identityIndex: null, + failureAddress: null, + coreFeePerByte: 1, + ...overrides, +}) + +describe('building a shielded spend transition', () => { + // The bundle builder is the only one that fans out, and reaching it through + // the SDK's flat transition map is not possible. + it('pays several recipients through the bundle builder', async () => { + const {sdk, createStateTransition, shieldedTransferMulti} = sdkStub() + + const transition = await buildTransition(sdk, 'testnet', payload({ + recipients: [ + {address: 'addr-a', amountCredits: 1_000n}, + {address: 'addr-b', amountCredits: 2_500n}, + ], + amountCredits: 3_500n, + }), [], ANCHOR, CHANGE) + + expect(transition).toBe('multi-transition') + expect(createStateTransition).not.toHaveBeenCalled() + + const outputs = shieldedTransferMulti.mock.calls[0][1] + expect(outputs.map((output: {address: {address: string}; amount: bigint}) => + [output.address.address, output.amount])).toEqual([['addr-a', 1_000n], ['addr-b', 2_500n]]) + }) + + // The amounts are the caller's to choose: nothing here splits a total, so two + // recipients of different sizes stay different sizes. + it('pays each recipient the amount it was given', async () => { + const {sdk, shieldedTransferMulti} = sdkStub() + + await buildTransition(sdk, 'testnet', payload({ + recipients: [ + {address: 'addr-a', amountCredits: 9n}, + {address: 'addr-b', amountCredits: 1n}, + {address: 'addr-c', amountCredits: 90n}, + ], + amountCredits: 100n, + }), [], ANCHOR, CHANGE) + + const outputs = shieldedTransferMulti.mock.calls[0][1] + expect(outputs.map((output: {amount: bigint}) => output.amount)).toEqual([9n, 1n, 90n]) + }) + + it('keeps a single recipient on the transition the SDK exposes', async () => { + const {sdk, createStateTransition, shieldedTransferMulti} = sdkStub() + + const transition = await buildTransition(sdk, 'testnet', payload({}), [], ANCHOR, CHANGE) + + expect(transition).toBe('single-transition') + expect(shieldedTransferMulti).not.toHaveBeenCalled() + expect(createStateTransition).toHaveBeenCalledWith('shieldedTransfer', expect.objectContaining({ + transferAmount: 1_000n, + })) + }) + + // amountCredits is what leaves the pool, which a fee-bearing kind can exceed + // its payout by; taking an output amount from it would overpay the recipient. + it('takes a payout from its own recipient rather than the pool total', async () => { + const {sdk, createStateTransition} = sdkStub() + + await buildTransition(sdk, 'testnet', payload({ + kind: 'unshield', + recipients: [{address: 'addr-a', amountCredits: 1_000n}], + amountCredits: 9_999n, + }), [], ANCHOR, CHANGE) + + expect(createStateTransition).toHaveBeenCalledWith('unshield', expect.objectContaining({ + unshieldAmount: 1_000n, + })) + }) + + // The one kind that pays no address funds itself from the pool total. + it('funds an identity from the amount rather than a recipient', async () => { + const {sdk, createStateTransition} = sdkStub() + + await buildTransition(sdk, 'testnet', payload({ + kind: 'identityCreateFromShielded', + recipients: [], + amountCredits: 40_000n, + identityIndex: 0, + failureAddress: 'refund-addr', + }), [], ANCHOR, CHANGE) + + expect(createStateTransition).toHaveBeenCalledWith('identityCreateFromShieldedPool', expect.objectContaining({ + denomination: 40_000n, + })) + }) +}) + +// The guard runs before the notes are recovered, so a refused payload costs no +// round trip and no proof. +describe('the recipients a spend payload may carry', () => { + const context = (): OperationContext => ({ + sdk: {} as DashPlatformSDK, + network: 'testnet', + signal: new AbortController().signal, + progress: () => undefined, + notesSpent: () => undefined, + } as unknown as OperationContext) + + it('refuses a transfer that names nobody', async () => { + await expect(spend(payload({recipients: [], amountCredits: 100n}), context())) + .rejects.toThrow(/shieldedTransfer takes/) + }) + + it('refuses a payout that names more than one address', async () => { + for (const kind of ['unshield', 'shieldedWithdrawal'] as const) { + await expect(spend(payload({ + kind, + recipients: [{address: 'a', amountCredits: 1n}, {address: 'b', amountCredits: 1n}], + amountCredits: 2n, + }), context())).rejects.toThrow(/takes 1 recipients at most/) + } + }) + + it('refuses an identity create that names an address', async () => { + await expect(spend(payload({ + kind: 'identityCreateFromShielded', + recipients: [{address: 'a', amountCredits: 1n}], + amountCredits: 100n, + }), context())).rejects.toThrow(/takes 0 recipients at most/) + }) + + it('refuses a transfer past the action cap', async () => { + const many = Array.from({length: MAX_SPEND_RECIPIENTS + 1}, (_, i) => + ({address: `addr-${i}`, amountCredits: 1n})) + await expect(spend(payload({recipients: many, amountCredits: BigInt(many.length)}), context())) + .rejects.toThrow(new RegExp(`takes ${MAX_SPEND_RECIPIENTS} recipients at most`)) + }) +}) From ed121b632125744b50b14d3dd709a23f2d8cbcea Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 18:21:37 +0300 Subject: [PATCH 12/31] bump sdk version and simplify shielded st creation --- package.json | 6 +-- .../shielded/spend/buildTransition.ts | 30 +++++------ tests/unit/shieldedBuildTransition.test.ts | 54 ++++++++----------- yarn.lock | 32 +++++------ 4 files changed, 55 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index 7f2237a7..049dcd6a 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,10 @@ "@scure/bip39": "^2.0.1", "class-variance-authority": "0.7.1", "classic-level": "^3.0.0", - "dash-core-p2p": "https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850", + "dash-core-p2p": "https://github.com/pshenmic/dash-core-p2p#7c40475607faf2b9a68d2f897a862b8e249cb2cc", "crypto-toothpick": "https://github.com/owl352/crypto-toothpick#900da4edf5df12eef28cfcb38059c4b69c8f4f77", - "dash-core-sdk": "1.1.3-dev.5", - "dash-platform-sdk": "1.5.0-dev.9", + "dash-core-sdk": "1.1.3-dev.6", + "dash-platform-sdk": "1.5.0-dev.10", "dash-ui-kit": "1.0.94", "electron-log": "^5.4.4", "knex": "^3.1.0", diff --git a/src/main/platform/operations/shielded/spend/buildTransition.ts b/src/main/platform/operations/shielded/spend/buildTransition.ts index 49f5d021..b47385ea 100644 --- a/src/main/platform/operations/shielded/spend/buildTransition.ts +++ b/src/main/platform/operations/shielded/spend/buildTransition.ts @@ -24,39 +24,35 @@ export async function buildTransition( changeAddress: ShieldedAddress, ): Promise { const {seed, recipients} = payload - const base = { + const spendInputs = { spends, changeAddress, seed, coinType: COIN_TYPE[network], account: SHIELDED_ACCOUNT, anchor, - memo: ShieldedMemoWASM.empty() as unknown as string, } + // A multi-output bundle carries a memo per output instead of one for the + // transition, so the memo is not part of what every spend shares. + const base = {...spendInputs, memo: ShieldedMemoWASM.empty() as unknown as string} switch (payload.kind) { - case 'shieldedTransfer': { - // The bundle builder is the only one that fans out, and it carries a memo - // per output rather than one for the transition, so its arguments do not - // fit createStateTransition's flat map. + case 'shieldedTransfer': if (recipients.length > 1) { - const builder = await sdk.shielded.getShieldedBuilder() - const outputs = recipients.map(recipient => new ShieldedOutputWASM( - OrchardAddressWASM.fromBech32m(recipient.address), - recipient.amountCredits, - ShieldedMemoWASM.empty(), - )) - const {stateTransition} = await builder.shieldedTransferMulti( - spends, outputs, changeAddress, seed, COIN_TYPE[network], SHIELDED_ACCOUNT, anchor, - ) - return stateTransition + return sdk.shielded.createStateTransition('shieldedTransferMulti', { + ...spendInputs, + outputs: recipients.map(recipient => new ShieldedOutputWASM( + OrchardAddressWASM.fromBech32m(recipient.address), + recipient.amountCredits, + ShieldedMemoWASM.empty(), + )), + }) } return sdk.shielded.createStateTransition('shieldedTransfer', { ...base, recipient: OrchardAddressWASM.fromBech32m(recipients[0].address), transferAmount: recipients[0].amountCredits, }) - } case 'unshield': return sdk.shielded.createStateTransition('unshield', { diff --git a/tests/unit/shieldedBuildTransition.test.ts b/tests/unit/shieldedBuildTransition.test.ts index 54c2a9d4..2bf9007d 100644 --- a/tests/unit/shieldedBuildTransition.test.ts +++ b/tests/unit/shieldedBuildTransition.test.ts @@ -26,22 +26,17 @@ const ANCHOR = new Uint8Array(32).fill(1) const SEED = new Uint8Array(64).fill(7) const CHANGE = {address: 'change'} as never -function sdkStub(): { - sdk: DashPlatformSDK - createStateTransition: ReturnType - shieldedTransferMulti: ReturnType -} { - const createStateTransition = vi.fn(async () => 'single-transition') - const shieldedTransferMulti = vi.fn(async () => ({stateTransition: 'multi-transition', fee: 6n})) - const sdk = { - shielded: { - createStateTransition, - getShieldedBuilder: async () => ({shieldedTransferMulti}), - }, - } as unknown as DashPlatformSDK - return {sdk, createStateTransition, shieldedTransferMulti} +function sdkStub(): {sdk: DashPlatformSDK; createStateTransition: ReturnType} { + const createStateTransition = vi.fn(async (type: string) => `${type}-transition`) + const sdk = {shielded: {createStateTransition}} as unknown as DashPlatformSDK + return {sdk, createStateTransition} } +const outputsOf = (createStateTransition: ReturnType): Array<{ + address: {address: string} + amount: bigint +}> => createStateTransition.mock.calls[0][1].outputs + const payload = (overrides: Partial): Payload => ({ seed: SEED, kind: 'shieldedTransfer', @@ -56,10 +51,10 @@ const payload = (overrides: Partial): Payload => ({ }) describe('building a shielded spend transition', () => { - // The bundle builder is the only one that fans out, and reaching it through - // the SDK's flat transition map is not possible. - it('pays several recipients through the bundle builder', async () => { - const {sdk, createStateTransition, shieldedTransferMulti} = sdkStub() + // Only the bundle transition fans out, and it carries no transition-wide memo + // — the single-recipient one does, so the two do not share a params shape. + it('pays several recipients through the bundle transition', async () => { + const {sdk, createStateTransition} = sdkStub() const transition = await buildTransition(sdk, 'testnet', payload({ recipients: [ @@ -69,18 +64,16 @@ describe('building a shielded spend transition', () => { amountCredits: 3_500n, }), [], ANCHOR, CHANGE) - expect(transition).toBe('multi-transition') - expect(createStateTransition).not.toHaveBeenCalled() - - const outputs = shieldedTransferMulti.mock.calls[0][1] - expect(outputs.map((output: {address: {address: string}; amount: bigint}) => - [output.address.address, output.amount])).toEqual([['addr-a', 1_000n], ['addr-b', 2_500n]]) + expect(transition).toBe('shieldedTransferMulti-transition') + expect(outputsOf(createStateTransition).map(output => [output.address.address, output.amount])) + .toEqual([['addr-a', 1_000n], ['addr-b', 2_500n]]) + expect(createStateTransition.mock.calls[0][1]).not.toHaveProperty('memo') }) // The amounts are the caller's to choose: nothing here splits a total, so two // recipients of different sizes stay different sizes. it('pays each recipient the amount it was given', async () => { - const {sdk, shieldedTransferMulti} = sdkStub() + const {sdk, createStateTransition} = sdkStub() await buildTransition(sdk, 'testnet', payload({ recipients: [ @@ -91,19 +84,18 @@ describe('building a shielded spend transition', () => { amountCredits: 100n, }), [], ANCHOR, CHANGE) - const outputs = shieldedTransferMulti.mock.calls[0][1] - expect(outputs.map((output: {amount: bigint}) => output.amount)).toEqual([9n, 1n, 90n]) + expect(outputsOf(createStateTransition).map(output => output.amount)).toEqual([9n, 1n, 90n]) }) - it('keeps a single recipient on the transition the SDK exposes', async () => { - const {sdk, createStateTransition, shieldedTransferMulti} = sdkStub() + it('keeps a single recipient on the single-output transition', async () => { + const {sdk, createStateTransition} = sdkStub() const transition = await buildTransition(sdk, 'testnet', payload({}), [], ANCHOR, CHANGE) - expect(transition).toBe('single-transition') - expect(shieldedTransferMulti).not.toHaveBeenCalled() + expect(transition).toBe('shieldedTransfer-transition') expect(createStateTransition).toHaveBeenCalledWith('shieldedTransfer', expect.objectContaining({ transferAmount: 1_000n, + memo: expect.anything(), })) }) diff --git a/yarn.lock b/yarn.lock index 7e2efd4e..5ce82d89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2970,18 +2970,18 @@ csstype@^3.2.2: resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -"dash-core-p2p@https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850": +"dash-core-p2p@https://github.com/pshenmic/dash-core-p2p#7c40475607faf2b9a68d2f897a862b8e249cb2cc": version "0.0.1" - resolved "https://github.com/pshenmic/dash-core-p2p#1b1d82bc0bc42f7ee9d00897eb4f594e56711850" + resolved "https://github.com/pshenmic/dash-core-p2p#7c40475607faf2b9a68d2f897a862b8e249cb2cc" dependencies: bloom-filter "^0.1.0" - dash-core-sdk "1.1.3-dev.5" + dash-core-sdk "1.1.3-dev.6" eventemitter3 "^5.0.1" -dash-core-sdk@1.1.3-dev.5: - version "1.1.3-dev.5" - resolved "https://registry.yarnpkg.com/dash-core-sdk/-/dash-core-sdk-1.1.3-dev.5.tgz#003eea05b8057066196ed938f7745232c3a872c8" - integrity sha512-45YhAmk6Dx4spmv6KIfRJMwRYoSrpyIzzx0LWsuIxyCGLeGOMcpOvvhc/C/mr7+2Qp0EsLITQKGP3BkHZeVGTg== +dash-core-sdk@1.1.3-dev.6: + version "1.1.3-dev.6" + resolved "https://registry.yarnpkg.com/dash-core-sdk/-/dash-core-sdk-1.1.3-dev.6.tgz#7f4e23d44ebbcf2f166f89b9e9ef803f2d948fc2" + integrity sha512-8kqJXrDDBjPIyk2ZRKKzkrdk0R7x1QSEGib6f/5lv8nfa+lBMzHA0QE9rjSBpR3jJRkND2RutcVEGJHB9TpyvA== dependencies: "@dashevo/x11-hash-js" "^1.0.2" "@noble/curves" "^2.0.1" @@ -2996,10 +2996,10 @@ dash-core-sdk@1.1.3-dev.5: typescript "^5.9.2" ws "^8.18.3" -dash-platform-sdk@1.5.0-dev.9: - version "1.5.0-dev.9" - resolved "https://registry.yarnpkg.com/dash-platform-sdk/-/dash-platform-sdk-1.5.0-dev.9.tgz#ca709366bc81c6e83d54604e9e45908d84df7804" - integrity sha512-vJ8K1NK8Tp3F3Ka3midMAEseRj11lwyaEb9XU5n6Ta0WRhe4umFNHFZfliEti2PWs/vmn7HX39a+eXAGQ7/E2A== +dash-platform-sdk@1.5.0-dev.10: + version "1.5.0-dev.10" + resolved "https://registry.yarnpkg.com/dash-platform-sdk/-/dash-platform-sdk-1.5.0-dev.10.tgz#7d23de5a0fb0b92bae7e6c34154e3019483735fc" + integrity sha512-gNQGzMbxR9u+wfAXD5NeCnHhoBItkjSmkWkD3h4A/QFxD/LTuQeaFPBEn2QJSClravmD6oAgkixyKXSQNyTiDQ== dependencies: "@bufbuild/protobuf" "^2.6.0" "@protobuf-ts/grpcweb-transport" "^2.11.1" @@ -3008,7 +3008,7 @@ dash-platform-sdk@1.5.0-dev.9: "@scure/bip39" "^2.0.0" "@scure/btc-signer" "^2.0.1" cbor-x "^1.6.0" - pshenmic-dpp "2.0.0-dev.28" + pshenmic-dpp "2.0.0-dev.29" dash-ui-kit@1.0.94: version "1.0.94" @@ -5447,10 +5447,10 @@ protoc@^32.1.0: resolved "https://registry.npmjs.org/protoc/-/protoc-32.1.0.tgz" integrity sha512-yICJJCGHJLM9ao5W2V4CGp1d7xuBsdHzgVDw6L8mdDtoIcqzN3arNPOm9Jx4Ufp5vfhQfJGkNUDo6mm/SLPdXw== -pshenmic-dpp@2.0.0-dev.28: - version "2.0.0-dev.28" - resolved "https://registry.yarnpkg.com/pshenmic-dpp/-/pshenmic-dpp-2.0.0-dev.28.tgz#3aec944bf9be86fd1faac5d06fb1d5d19974487c" - integrity sha512-2nfTDl7jRJ3uANspLkYjFzTMSMBLPUsPS1OezTVEEBFkl86MQ7M6JGlVWF7S4rAUdD0cjYu9NbDMz7jhw9LUNQ== +pshenmic-dpp@2.0.0-dev.29: + version "2.0.0-dev.29" + resolved "https://registry.yarnpkg.com/pshenmic-dpp/-/pshenmic-dpp-2.0.0-dev.29.tgz#e67d6fd53a3f9ffb7f4cff9a776993dfc8b6e338" + integrity sha512-vdlXHy7lvwvbB69UWqCx3YS7ROb+U2md2V9qO/0nEgZCO3eh6cOiQfrYYCb1g+mJWxAj4v3Mp+awzAi+r8ZDaQ== dependencies: "@emnapi/core" "1.9.0" "@emnapi/runtime" "1.9.0" From a660d8008c29eda42d8112ee61e960863bcbcfa1 Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 18:34:21 +0300 Subject: [PATCH 13/31] remove logs for platform transfers --- .../platform/PlatformTransferService.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/main/src/services/platform/PlatformTransferService.ts b/src/main/src/services/platform/PlatformTransferService.ts index 5f19255f..7fe788e2 100644 --- a/src/main/src/services/platform/PlatformTransferService.ts +++ b/src/main/src/services/platform/PlatformTransferService.ts @@ -84,7 +84,6 @@ export class PlatformTransferService { platformSource: source, }) if (plan === null) throw new Error(error) - this.logPlan('addressFundsTransfer', plan, amountCredits) const {stHash} = await this.platform.request('addressTransfer', network, { seed, @@ -196,7 +195,6 @@ export class PlatformTransferService { amountCredits, recipient: '', platformSource: source, }) if (plan === null) throw new Error(error) - this.logPlan('identityCreate', plan, amountCredits) const {stHash, identifier} = await this.platform.request('identityCreateFromAddresses', network, { seed, @@ -243,7 +241,6 @@ export class PlatformTransferService { amountCredits, recipient: identityId, platformSource: source, }) if (plan === null) throw new Error(error) - this.logPlan('identityTopUp', plan, amountCredits) const {stHash} = await this.platform.request('identityTopUpFromAddresses', network, { seed, @@ -279,7 +276,6 @@ export class PlatformTransferService { amountCredits, recipient: toCoreAddress, platformSource: source, }) if (plan === null) throw new Error(error) - this.logPlan('addressWithdrawal', plan, amountCredits) const {stHash} = await this.platform.request('addressWithdrawal', network, { seed, @@ -448,22 +444,6 @@ export class PlatformTransferService { } - // A consensus refusal reports only the figure it required; reconciling it - // needs the count priced and what each address keeps back. - private logPlan(operation: SelectionFeeOperation, plan: PlatformInputPlan, amountCredits: bigint): void { - const inputs = plan.inputs - .map(({candidate, credits}) => `${candidate.platformAddress} spends=${credits} of=${candidate.balanceCredits} nonce=${candidate.nonce}`) - .join(' | ') - const strategy = plan.feeStrategy - .map(step => step.kind === 'deductFromInput' ? `input[${step.index}]` : `output[${step.index}]`) - .join(',') - - console.log( - `[platform] ${operation}: amount=${amountCredits} fee=${plan.feeCredits} ` - + `inputs=${plan.inputs.length} feeFrom=${strategy} | ${inputs}`, - ) - } - private async requireIdentity(walletId: string, identifier: string): Promise { const identities = await this.identityDAO.getIdentitiesByWalletId(walletId) const identity = identities.find(entry => entry.identifier === identifier) From a5333f0fad310f9104ed192bdb2254eb3c51df52 Mon Sep 17 00:00:00 2001 From: owl352 Date: Tue, 1 Sep 2026 18:52:59 +0300 Subject: [PATCH 14/31] tiny fixes --- .../operations/shielded/spend/spend.ts | 15 ++++++- .../platform/PlatformTransferService.ts | 6 +-- src/main/src/services/wallet/WalletService.ts | 5 ++- src/main/src/types/SendResult.ts | 4 +- src/main/src/utils/platformTransfer.ts | 24 +++++----- src/main/src/utils/shieldedNoteSelection.ts | 19 +++++--- src/renderer/src/api/types.ts | 1 - tests/api/walletUtxos.test.ts | 15 ++++++- tests/unit/platformTransfer.test.ts | 39 +++++++++------- tests/unit/shieldedNoteSelection.test.ts | 44 +++++++++++++++++-- 10 files changed, 127 insertions(+), 45 deletions(-) diff --git a/src/main/platform/operations/shielded/spend/spend.ts b/src/main/platform/operations/shielded/spend/spend.ts index 621670a2..e4227da6 100644 --- a/src/main/platform/operations/shielded/spend/spend.ts +++ b/src/main/platform/operations/shielded/spend/spend.ts @@ -1,5 +1,11 @@ import {IdentityCreateFromShieldedPoolTransitionWASM, RecoveredNoteWASM} from 'pshenmic-dpp' -import {bundleActions, maxSpendableCredits, selectableNotes, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' +import { + bundleActions, + maxSpendableCredits, + picksMissingNotes, + selectableNotes, + selectSpendNotes, +} from '../../../../src/utils/shieldedNoteSelection' import {PlatformOperations} from '../../../types/messages' import {OperationContext, OperationError, throwIfAborted} from '../../types' import {consensusMessage} from '../../consensusMessage' @@ -54,6 +60,13 @@ export async function spend(payload: Payload, ctx: OperationContext): Promise ({index: poolIndex(recoveredNote), value: recoveredNote.note.value, spent})), payload.source, ) + + // The quote prices whatever a stale pick still holds; the spend is where that + // becomes a refusal, so it names the notes rather than reporting a shortfall. + if (picksMissingNotes(selectable, payload.source)) { + throw new OperationError('Selected note is no longer spendable', 'insufficientFunds') + } + const selection = selectSpendNotes(selectable, amount, MAX_SPEND_NOTES, fee, payload.source) if (selection == null) { const max = maxSpendableCredits(selectable, MAX_SPEND_NOTES, fee, payload.source) diff --git a/src/main/src/services/platform/PlatformTransferService.ts b/src/main/src/services/platform/PlatformTransferService.ts index 7fe788e2..6903d5e4 100644 --- a/src/main/src/services/platform/PlatformTransferService.ts +++ b/src/main/src/services/platform/PlatformTransferService.ts @@ -6,7 +6,7 @@ import {ShieldedService} from './ShieldedService' import {IdentityDAO} from '../../database/IdentityDAO' import {AssetLockFundingState} from '../../types/AssetLockFunding' import {CoreSpendSource} from '../../types/CoinSelection' -import {PlatformInputPlan, PlatformSpendSource} from '../../types/PlatformTransfer' +import {PlatformSpendSource} from '../../types/PlatformTransfer' import {Network} from '../../types/Network' import {Wallet} from '../../types/Wallet' import {Identity} from '../../types/Identity' @@ -23,7 +23,7 @@ import {coreFeePerByte} from '../../utils/coreFeeRate' import {Preferences} from '../../preferences' import {AcquiredAssetLock, AssetLockFundingRow} from '../../types/AssetLock' import {FeeService} from '../wallet/FeeService' -import {Recipient, SelectionFeeOperation} from '../../../platform/types/messages' +import {Recipient} from '../../../platform/types/messages' // Every way credits move on L2: between platform addresses, to and from @@ -113,7 +113,7 @@ export class PlatformTransferService { const network = wallet.network const identity = await this.requireIdentity(walletId, identityIdentifier) const feeCredits = await this.fee.requireFee(walletId, 'identityToAddress', { - amountCredits: recipients[0].amountCredits, + amountCredits: totalCredits, recipient: recipients.map(entry => entry.address), identityId: identityIdentifier, }) diff --git a/src/main/src/services/wallet/WalletService.ts b/src/main/src/services/wallet/WalletService.ts index 3d9c1648..9a9b904e 100644 --- a/src/main/src/services/wallet/WalletService.ts +++ b/src/main/src/services/wallet/WalletService.ts @@ -262,10 +262,14 @@ export class WalletService { return provider.getWalletTransactions() } + // Guarded like a send rather than like a read: a picker fed a partial set does + // not under-display, it narrows what the user can pick to coins they cannot + // tell are only some of theirs. async getUtxos(walletId: string): Promise { const wallet = await requireWallet(this.walletDAO, walletId) const provider = this.providers.forWallet(wallet.walletId, wallet.network) + await provider.ensureReady() const grouped = await this.addressDAO.getAddressesByWalletId(walletId) return selectableTransferUtxos(grouped, await provider.getWalletUtxos()) @@ -365,7 +369,6 @@ export class WalletService { txid: broadcast.txid, amount: amountDuffs, fee: actualFee, - toAddress: recipients[0].address, changeAddress: hasChange ? changeAddress : null, peersAcked: broadcast.peersDelivered.length, } diff --git a/src/main/src/types/SendResult.ts b/src/main/src/types/SendResult.ts index a4d64df3..da7a076e 100644 --- a/src/main/src/types/SendResult.ts +++ b/src/main/src/types/SendResult.ts @@ -1,8 +1,10 @@ +// amount is what every recipient was paid together. No single toAddress: one +// transaction pays many, and naming the first would be a false summary of the +// rest. export interface SendResult { txid: string amount: bigint fee: bigint - toAddress: string changeAddress: string | null peersAcked: number } diff --git a/src/main/src/utils/platformTransfer.ts b/src/main/src/utils/platformTransfer.ts index c3a1932a..816f7d44 100644 --- a/src/main/src/utils/platformTransfer.ts +++ b/src/main/src/utils/platformTransfer.ts @@ -52,17 +52,12 @@ export function selectablePlatformInputs( candidates: PlatformSourceCandidate[], source?: PlatformSpendSource | null, ): PlatformSourceCandidate[] { - // A picked set names its addresses, so one that can no longer cover what it - // was allowed to draw is a refusal rather than one fewer candidate. + // A pick names its addresses; whether they still hold what it draws is the + // plan's to refuse, not this filter's to throw on. A quote asks before the + // pick is affordable and has to answer a stale one rather than fail on it. if (source?.kind === 'inputs') { - const held = new Map(candidates.map(candidate => [candidate.platformAddress, candidate])) - return source.inputs.map(input => { - const candidate = held.get(input.address) - if (candidate == null || candidate.balanceCredits < input.credits) { - throw new Error('Selected address no longer holds these credits') - } - return candidate - }) + const picked = new Set(source.inputs.map(input => input.address)) + return candidates.filter(candidate => picked.has(candidate.platformAddress)) } return candidates @@ -214,6 +209,15 @@ function planPickedInputs( if (candidate == null) { return refuse('Source address not found in this wallet') } + if (candidate.balanceCredits < input.credits) { + return refuse('Selected address no longer holds these credits') + } + // Every picked address has to become an input of its own, and one drawing + // less than the protocol floor cannot. Refused rather than allocated around, + // which would fund the transition from fewer addresses than were picked. + if (input.credits < MIN_INPUT_CREDITS) { + return refuse(`Each selected address must fund at least ${MIN_INPUT_CREDITS.toString()} credits`) + } allocatable.push({candidate, cap: input.credits}) } diff --git a/src/main/src/utils/shieldedNoteSelection.ts b/src/main/src/utils/shieldedNoteSelection.ts index 694db697..e8051da3 100644 --- a/src/main/src/utils/shieldedNoteSelection.ts +++ b/src/main/src/utils/shieldedNoteSelection.ts @@ -44,17 +44,19 @@ export function selectableNotes( source?: ShieldedSpendSource | null, ): SelectableNote[] { const restricted = source == null ? null : new Set(source.noteIndexes) - const selectable = notes + return notes .filter(note => !note.spent) .filter(note => restricted == null || restricted.has(note.index)) .map(({index, value}) => ({index, value})) +} - // A narrowed spend prices whatever survived, but one that quietly used fewer - // notes than were picked would break the promise picking them makes. - if (source?.kind === 'notes' && selectable.length !== source.noteIndexes.length) { - throw new Error('Selected note is no longer spendable') - } - return selectable +// Whether a pick still holds every note it named. A quote answers a stale pick +// with what it can no longer fund; only the spend itself refuses on one. +export function picksMissingNotes( + selectable: SelectableNote[], + source?: ShieldedSpendSource | null, +): boolean { + return source?.kind === 'notes' && selectable.length !== source.noteIndexes.length } export function selectSpendNotes( @@ -66,8 +68,10 @@ export function selectSpendNotes( ): NoteSelectionResult | null { // A picked set is spent whole rather than walked: stopping early would leave // out notes the user asked to spend, which is the one thing picking them means. + // So a pick short of a note it named funds nothing, rather than less. if (source?.kind === 'notes') { if (notes.length === 0 || notes.length > maxNotes) return null + if (picksMissingNotes(notes, source)) return null const total = totalOf(notes) const feeCredits = feeForCount(notes.length) return total >= amount + feeCredits ? {selected: [...notes], total, feeCredits} : null @@ -96,6 +100,7 @@ export function maxSpendableCredits( // one the picked count carries, whether or not a smaller set would be cheaper. if (source?.kind === 'notes') { if (notes.length === 0 || notes.length > maxNotes) return 0n + if (picksMissingNotes(notes, source)) return 0n const spendable = totalOf(notes) - feeForCount(notes.length) return spendable > 0n ? spendable : 0n } diff --git a/src/renderer/src/api/types.ts b/src/renderer/src/api/types.ts index eadd71aa..323e14e3 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -264,7 +264,6 @@ export interface SendResult { txid: string amount: bigint fee: bigint - toAddress: string changeAddress: string | null peersAcked: number } diff --git a/tests/api/walletUtxos.test.ts b/tests/api/walletUtxos.test.ts index 38319781..1034d054 100644 --- a/tests/api/walletUtxos.test.ts +++ b/tests/api/walletUtxos.test.ts @@ -14,11 +14,13 @@ const FOREIGN = 'yPx8DNt1oQt3yubB2Sh73vAQRQ1AoyyLCS' const utxo = (address: string, satoshis: bigint, txId: string, height: number): UTXO => ({address, satoshis, txId, vOut: 0, script: Script.fromHex(SCRIPT_HEX), height}) -const providerStub = (utxos: UTXO[]): WalletProvider => ({ +const providerStub = (utxos: UTXO[], ready = true): WalletProvider => ({ getWalletUtxos: async () => utxos, getWalletBalance: async () => 0n, getBalance: async () => 0n, - ensureReady: async () => undefined, + ensureReady: async () => { + if (!ready) throw new Error('Wallet sync is not complete') + }, getConnectionStatus: async () => 'online', scanAddressUsage: async () => null, getUsedAddresses: async () => [], @@ -70,4 +72,13 @@ describe('listing the coins a send can draw on', () => { expect((await walletService.getUtxos(walletId))[0].height).toBe(0) }) + + // Listing a partial set does not under-display, it narrows what can be picked + // to coins the user cannot tell are only some of theirs. + it('refuses to list coins a source is not ready to answer for', async () => { + vi.spyOn(providers, 'forWallet') + .mockReturnValue(providerStub([utxo(owned, 50_000n, 'aa', 2_300_000)], false)) + + await expect(walletService.getUtxos(walletId)).rejects.toThrow('sync is not complete') + }) }) diff --git a/tests/unit/platformTransfer.test.ts b/tests/unit/platformTransfer.test.ts index f116df5d..76ec8586 100644 --- a/tests/unit/platformTransfer.test.ts +++ b/tests/unit/platformTransfer.test.ts @@ -283,19 +283,20 @@ describe('funding a transition from picked inputs', () => { expect(plan!.inputs.reduce((sum, entry) => sum + entry.credits, 0n)).toBe(2_000_000n) }) - // A share below the protocol minimum cannot be its own input, so the address - // paying the fee carries it instead. - it('leaves out a picked address whose share would be below the minimum', () => { - const {plan} = inputsFor( + // A share below the protocol minimum cannot be its own input, and funding the + // transition from the rest would spend from fewer addresses than were picked. + it('refuses a picked address whose share would be below the minimum', () => { + const outcome = inputsFor( candidates, 2_000_000n, INPUT_FEE, pick([input('a', 2_000_000n), input('b', MIN_INPUT_CREDITS - 1n)])) - expect(plan!.inputs.map(entry => entry.candidate.platformAddress)).toEqual(['a']) + expect(outcome.plan).toBeNull() + expect(outcome.error).toMatch(/at least/) }) it('refuses an input larger than its address holds', () => { - expect(() => inputsFor(candidates, 6_000_000n, INPUT_FEE, pick([input('a', 6_000_000n)]))) - .toThrow('no longer holds') + expect(inputsFor(candidates, 6_000_000n, INPUT_FEE, pick([input('a', 6_000_000n)])).error) + .toMatch(/no longer holds/) }) // Each input carries the address's next nonce, so a second one would replay it. @@ -328,8 +329,8 @@ describe('funding a transition from picked inputs', () => { }) it('refuses an address this wallet does not hold', () => { - expect(() => inputsFor(candidates, 1_000_000n, INPUT_FEE, pick([input('zzz', 1_000_000n)]))) - .toThrow('no longer holds') + expect(inputsFor(candidates, 1_000_000n, INPUT_FEE, pick([input('zzz', 1_000_000n)])).error) + .toMatch(/not found in this wallet/) }) it('refuses when the fee payer does not keep back the fee', () => { @@ -417,16 +418,24 @@ describe('selectablePlatformInputs', () => { expect(selectablePlatformInputs([candidate('a', 5_000_000n)], from('zzz'))).toEqual([]) }) - it('refuses a picked address whose balance no longer covers its input', () => { + // A quote asks before the pick is affordable, so this filter answers a stale + // one with what survived and leaves the refusal to the plan over it. + it('keeps a picked address whose balance no longer covers its input', () => { const candidates = [candidate('a', 1_000_000n)] - expect(() => selectablePlatformInputs(candidates, pick([input('a', 2_000_000n)]))) - .toThrow('no longer holds') + expect(selectablePlatformInputs(candidates, pick([input('a', 2_000_000n)])).map(e => e.platformAddress)) + .toEqual(['a']) + }) + + it('keeps a picked address the automatic walk would drop as too small', () => { + const candidates = [candidate('a', MIN_INPUT_CREDITS - 1n)] + + expect(selectablePlatformInputs(candidates, pick([input('a', MIN_INPUT_CREDITS)])).map(e => e.platformAddress)) + .toEqual(['a']) }) - it('refuses a picked address this wallet does not hold', () => { - expect(() => selectablePlatformInputs([candidate('a', 5_000_000n)], pick([input('zzz', 1_000_000n)]))) - .toThrow('no longer holds') + it('yields nothing for a picked address this wallet does not hold', () => { + expect(selectablePlatformInputs([candidate('a', 5_000_000n)], pick([input('zzz', 1_000_000n)]))).toEqual([]) }) }) diff --git a/tests/unit/shieldedNoteSelection.test.ts b/tests/unit/shieldedNoteSelection.test.ts index 23780fee..7b198916 100644 --- a/tests/unit/shieldedNoteSelection.test.ts +++ b/tests/unit/shieldedNoteSelection.test.ts @@ -3,6 +3,7 @@ import { bundleActions, maxSpendableCredits, requireShieldedRecipients, + picksMissingNotes, selectableNotes, selectSpendNotes, } from '../../src/main/src/utils/shieldedNoteSelection' @@ -124,14 +125,49 @@ describe('selectableNotes', () => { expect(selectableNotes(notes, {kind: 'address', noteIndexes: [0, 1]}).map(n => n.index)).toEqual([0]) }) - it('refuses a picked note that was spent since it was picked', () => { + // A quote asks before the pick is affordable, so a stale one is answered with + // what survived rather than thrown on; picksMissingNotes is what reports it. + it('drops a picked note that was spent since it was picked', () => { const notes = [owned(0, 100n), owned(1, 50n, true)] - expect(() => selectableNotes(notes, picked(0, 1))).toThrow('no longer spendable') + expect(selectableNotes(notes, picked(0, 1)).map(n => n.index)).toEqual([0]) + expect(picksMissingNotes(selectableNotes(notes, picked(0, 1)), picked(0, 1))).toBe(true) }) - it('refuses a picked note the wallet does not hold', () => { - expect(() => selectableNotes([owned(0, 100n)], picked(0, 7))).toThrow('no longer spendable') + it('reports a picked note the wallet does not hold as missing', () => { + expect(picksMissingNotes(selectableNotes([owned(0, 100n)], picked(0, 7)), picked(0, 7))).toBe(true) + }) + + it('reports a pick that still holds every note it named as intact', () => { + const notes = [owned(0, 100n), owned(1, 50n)] + + expect(picksMissingNotes(selectableNotes(notes, picked(0, 1)), picked(0, 1))).toBe(false) + }) + + // An address source names where to draw from, not what to spend, so a spent + // note there is one fewer candidate and never a missing pick. + it('never reports an address source as missing notes', () => { + const source = {kind: 'address' as const, noteIndexes: [0, 1]} + + expect(picksMissingNotes(selectableNotes([owned(0, 100n), owned(1, 50n, true)], source), source)).toBe(false) + }) +}) + +describe('a pick short of a note it named', () => { + const fee = (count: number): bigint => BigInt(count) * 10n + + it('funds nothing rather than funding less', () => { + const source = picked(0, 1) + const survived = selectableNotes([owned(0, 100n), owned(1, 50n, true)], source) + + expect(selectSpendNotes(survived, 10n, 6, fee, source)).toBeNull() + }) + + it('offers no maximum to send', () => { + const source = picked(0, 1) + const survived = selectableNotes([owned(0, 100n), owned(1, 50n, true)], source) + + expect(maxSpendableCredits(survived, 6, fee, source)).toBe(0n) }) }) From ca6a72f043da1c9973102de6933df35496ade85d Mon Sep 17 00:00:00 2001 From: owl352 Date: Sat, 5 Sep 2026 21:15:34 +0300 Subject: [PATCH 15/31] remove unused import --- src/main/src/services/core/WalletSyncService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/src/services/core/WalletSyncService.ts b/src/main/src/services/core/WalletSyncService.ts index 3d5445f2..63c315e5 100644 --- a/src/main/src/services/core/WalletSyncService.ts +++ b/src/main/src/services/core/WalletSyncService.ts @@ -15,7 +15,7 @@ import {AddressDAO} from '../../database/AddressDAO' import {TransactionDAO} from '../../database/TransactionDAO' import {P2PCommand, P2PEvent} from '../../../p2p/types/messages' import {BroadcastPolicyOverrides, BroadcastResult} from '../../../p2p/types/broadcast' -import {AppliedBlock, AppliedTx, GapExhausted, WalletSyncStatus, WalletSyncUtxo, WatchAddress} from '../../../p2p/types/walletSync' +import {AppliedBlock, AppliedTx, GapExhausted, WalletSyncStatus, WatchAddress} from '../../../p2p/types/walletSync' import {PeerInfo, PeerProbeResult} from '../../../p2p/types/pool' import {randomUUID} from 'crypto' import {GENESIS} from '../../../p2p/constants' From 7522485fcaf2f55a117a75268a4ce1b1c21224b2 Mon Sep 17 00:00:00 2001 From: 0x1337 Date: Thu, 3 Sep 2026 23:06:05 +0700 Subject: [PATCH 16/31] feat: coin control UI wip --- .../pages/transfer/CoinControlModal.tsx | 457 ++++++++++++++++++ .../pages/transfer/CoreUtxoPicker.tsx | 87 ---- .../pages/transfer/PlatformInputPicker.tsx | 111 ----- .../pages/transfer/ShieldedNotePicker.tsx | 88 ---- .../components/pages/transfer/TransferHub.tsx | 391 +++++++-------- src/renderer/src/enums/CoinControlMode.ts | 5 + src/renderer/src/types/CoinControl.ts | 16 + src/renderer/src/types/SendDraft.ts | 2 - src/renderer/src/types/SpecificSource.ts | 8 - src/renderer/src/utils/coinControl.ts | 138 ++++++ src/renderer/src/utils/sendDraft.ts | 2 - src/renderer/src/utils/specificSource.ts | 67 --- tests/unit/coinControl.test.ts | 131 +++++ tests/unit/sendDraft.test.ts | 8 - tests/unit/specificSource.test.ts | 60 --- 15 files changed, 924 insertions(+), 647 deletions(-) create mode 100644 src/renderer/src/components/pages/transfer/CoinControlModal.tsx delete mode 100644 src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx delete mode 100644 src/renderer/src/components/pages/transfer/PlatformInputPicker.tsx delete mode 100644 src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx create mode 100644 src/renderer/src/enums/CoinControlMode.ts create mode 100644 src/renderer/src/types/CoinControl.ts delete mode 100644 src/renderer/src/types/SpecificSource.ts create mode 100644 src/renderer/src/utils/coinControl.ts delete mode 100644 src/renderer/src/utils/specificSource.ts create mode 100644 tests/unit/coinControl.test.ts delete mode 100644 tests/unit/specificSource.test.ts diff --git a/src/renderer/src/components/pages/transfer/CoinControlModal.tsx b/src/renderer/src/components/pages/transfer/CoinControlModal.tsx new file mode 100644 index 00000000..1ee32214 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/CoinControlModal.tsx @@ -0,0 +1,457 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { useTheme } from 'dash-ui-kit/react' +import { Button, CreditsIcon, CrossIcon, ShieldSmallIcon, Text } from '@renderer/components/dash-ui-kit-enxtended' +import { DashLogo } from 'dash-ui-kit/react' +import Checkbox from '@renderer/components/ui/Checkbox' +import CreditsAmount from '@renderer/components/ui/CreditsAmount' +import type { PlatformAddressDto, SelectableUtxo, ShieldedNoteInfo, WalletAddressDto } from '@renderer/api/types' +import { PLATFORM_INPUT_LIMIT } from '@renderer/constants/platform' +import { SHIELDED_NOTE_LIMIT } from '@renderer/constants/shielded' +import { SourceKind } from '@renderer/enums/SourceKind' +import { TransferOperation } from '@renderer/enums/TransferOperation' +import { CoinControlMode } from '@renderer/enums/CoinControlMode' +import type { CoinControlSelection } from '@renderer/types/CoinControl' +import { automaticCoinControl, coinControlSourceKind, outpointKey } from '@renderer/utils/coinControl' +import { davToDashCompact } from '@renderer/utils/balance' + +const FIXED_SOURCE_COPY: Partial> = { + [TransferOperation.Shield]: { + title: 'Selected Platform address', + description: 'Shielding spends one source address as a single input. Change it in the From field.', + }, + [TransferOperation.IdentityCreateFromShielded]: { + title: 'Automatic shielded selection', + description: 'Manual note selection is not available for identity creation from the shielded pool.', + }, +} + +const INPUT_MODE_LABEL: Record = { + [SourceKind.Core]: 'Coins', + [SourceKind.PlatformAddress]: 'Inputs', + [SourceKind.Identity]: 'Inputs', + [SourceKind.Shielded]: 'Notes', +} + +interface CoinControlModalProps { + isOpen: boolean + operation: TransferOperation | null + selection: CoinControlSelection + coreAddresses: WalletAddressDto[] + utxos: SelectableUtxo[] + platformAddresses: PlatformAddressDto[] + shieldedNotes: ShieldedNoteInfo[] + identityLabel: string | null + identityId: string | null + platformAddress: PlatformAddressDto | undefined + onClose: () => void + onApply: (selection: CoinControlSelection) => void +} + +export default function CoinControlModal({ + isOpen, + operation, + selection, + coreAddresses, + utxos, + platformAddresses, + shieldedNotes, + identityLabel, + identityId, + platformAddress, + onClose, + onApply, +}: CoinControlModalProps): React.JSX.Element | null { + const {theme} = useTheme() + const [draft, setDraft] = useState(selection) + + useEffect(() => { + if (isOpen) setDraft(selection) + }, [isOpen, selection]) + + if (!isOpen || operation == null) return null + + const sourceKind = coinControlSourceKind(operation) + const shieldedAddresses = [...new Set(shieldedNotes.map(note => note.address))] + const selectedPlatformInputs = draft.kind === 'platformInputs' ? draft.inputs : [] + const platformInputsValid = draft.kind !== 'platformInputs' || ( + draft.inputs.length > 0 + && draft.inputs.length <= PLATFORM_INPUT_LIMIT + && draft.inputs.some(input => input.address === draft.feeAddress) + && draft.inputs.every(input => { + const address = platformAddresses.find(entry => entry.platformAddress === input.address) + return address != null && input.credits > 0n && input.credits <= address.balanceCredits + }) + ) + let canApply = false + switch (draft.kind) { + case 'automatic': + canApply = true + break + case 'coreAddress': + canApply = coreAddresses.some(entry => entry.address === draft.address) + break + case 'coreOutpoints': + canApply = draft.outpoints.length > 0 + && draft.outpoints.every(outpoint => utxos.some(utxo => outpointKey(utxo) === outpoint)) + break + case 'platformAddress': + canApply = platformAddresses.some(entry => entry.platformAddress === draft.address) + break + case 'platformInputs': + canApply = platformInputsValid + break + case 'shieldedAddress': + canApply = shieldedAddresses.includes(draft.address) + break + case 'shieldedNotes': + canApply = draft.noteIndexes.length > 0 + && draft.noteIndexes.length <= SHIELDED_NOTE_LIMIT + && draft.noteIndexes.every(index => shieldedNotes.some(note => note.index === index)) + break + } + + const chooseMode = (nextMode: CoinControlMode): void => { + if (nextMode === CoinControlMode.Automatic) { + setDraft(automaticCoinControl()) + return + } + + switch (sourceKind) { + case SourceKind.Core: + if (nextMode === CoinControlMode.Address) { + setDraft({kind: 'coreAddress', address: coreAddresses[0]?.address ?? ''}) + } else { + setDraft({kind: 'coreOutpoints', outpoints: []}) + } + break + case SourceKind.PlatformAddress: + if (nextMode === CoinControlMode.Address) { + setDraft({kind: 'platformAddress', address: platformAddresses[0]?.platformAddress ?? ''}) + } else { + setDraft({kind: 'platformInputs', inputs: [], feeAddress: ''}) + } + break + case SourceKind.Shielded: + if (nextMode === CoinControlMode.Address) { + setDraft({kind: 'shieldedAddress', address: shieldedAddresses[0] ?? ''}) + } else { + setDraft({kind: 'shieldedNotes', noteIndexes: []}) + } + break + } + } + + let mode = CoinControlMode.Inputs + switch (draft.kind) { + case 'automatic': + mode = CoinControlMode.Automatic + break + case 'coreAddress': + case 'platformAddress': + case 'shieldedAddress': + mode = CoinControlMode.Address + break + } + + const modeButton = (value: CoinControlMode, label: string): React.JSX.Element => ( + + ) + + const fixed = sourceKind == null + const inputModeLabel = sourceKind == null ? 'Inputs' : INPUT_MODE_LABEL[sourceKind] + const fixedCopy = FIXED_SOURCE_COPY[operation] ?? { + title: 'Selected identity', + description: 'Identity operations spend the selected identity balance. Change it in the From field.', + } + let fixedValue = identityLabel ?? identityId ?? 'No identity selected' + if (operation === TransferOperation.Shield) { + fixedValue = platformAddress?.platformAddress ?? 'No funded Platform address' + } else if (operation === TransferOperation.IdentityCreateFromShielded) { + fixedValue = 'The wallet selects notes for this operation.' + } + + const toggleCoreOutpoint = (key: string, checked: boolean): void => { + let outpoints: string[] = [] + if (draft.kind === 'coreOutpoints') outpoints = draft.outpoints + if (checked) { + setDraft({kind: 'coreOutpoints', outpoints: [...outpoints, key]}) + } else { + setDraft({kind: 'coreOutpoints', outpoints: outpoints.filter(value => value !== key)}) + } + } + + const togglePlatformInput = (entry: PlatformAddressDto, checked: boolean): void => { + if (checked && selectedPlatformInputs.length >= PLATFORM_INPUT_LIMIT) return + if (checked) { + const inputs = [...selectedPlatformInputs, {address: entry.platformAddress, credits: entry.balanceCredits}] + const feeAddress = draft.kind === 'platformInputs' && draft.feeAddress + ? draft.feeAddress + : entry.platformAddress + setDraft({kind: 'platformInputs', inputs, feeAddress}) + return + } + + const inputs = selectedPlatformInputs.filter(input => input.address !== entry.platformAddress) + let feeAddress = inputs[0]?.address ?? '' + if (draft.kind === 'platformInputs' && draft.feeAddress !== entry.platformAddress) { + feeAddress = draft.feeAddress + } + setDraft({kind: 'platformInputs', inputs, feeAddress}) + } + + const setPlatformInputCredits = (address: string, value: string): void => { + const credits = BigInt(value.replace(/\D/g, '') || '0') + let feeAddress = address + if (draft.kind === 'platformInputs') feeAddress = draft.feeAddress + setDraft({ + kind: 'platformInputs', + inputs: selectedPlatformInputs.map(input => input.address === address ? {...input, credits} : input), + feeAddress, + }) + } + + const toggleShieldedNote = (index: number, checked: boolean): void => { + let noteIndexes: number[] = [] + if (draft.kind === 'shieldedNotes') noteIndexes = draft.noteIndexes + if (checked) { + if (noteIndexes.length >= SHIELDED_NOTE_LIMIT) return + setDraft({kind: 'shieldedNotes', noteIndexes: [...noteIndexes, index]}) + } else { + setDraft({kind: 'shieldedNotes', noteIndexes: noteIndexes.filter(noteIndex => noteIndex !== index)}) + } + } + + const apply = (): void => { + if (fixed) { + onClose() + return + } + onApply(draft) + onClose() + } + + return createPortal( +
+
+
+
+
Coin control
+ + Choose which funds this transfer may spend. + +
+ +
+ +
+ {fixed ? ( +
+ {fixedCopy.title} + {fixedValue} + + {fixedCopy.description} + +
+ ) : ( + <> +
+ {modeButton(CoinControlMode.Automatic, 'Automatic')} + {modeButton(CoinControlMode.Address, 'One address')} + {modeButton(CoinControlMode.Inputs, inputModeLabel)} +
+ + {mode === CoinControlMode.Automatic && ( +
+ Let the wallet choose + + The wallet will select enough available inputs for the amount and fee. + +
+ )} + + {mode === CoinControlMode.Address && sourceKind === SourceKind.Core && ( +
+ {coreAddresses.length === 0 && } + {coreAddresses.map(entry => ( + setDraft({kind: 'coreAddress', address: entry.address})}> + + + + ))} +
+ )} + + {mode === CoinControlMode.Address && sourceKind === SourceKind.PlatformAddress && ( +
+ {platformAddresses.length === 0 && } + {platformAddresses.map(entry => ( + setDraft({kind: 'platformAddress', address: entry.platformAddress})}> + + } /> + + ))} +
+ )} + + {mode === CoinControlMode.Address && sourceKind === SourceKind.Shielded && ( +
+ {shieldedAddresses.length === 0 && } + {shieldedAddresses.map(address => { + const total = shieldedNotes.filter(note => note.address === address).reduce((sum, note) => sum + note.amount, 0n) + return ( + setDraft({kind: 'shieldedAddress', address})}> + + } /> + + ) + })} +
+ )} + + {mode === CoinControlMode.Inputs && sourceKind === SourceKind.Core && ( +
+ {utxos.length === 0 && } + {utxos.map(utxo => { + const key = outpointKey(utxo) + const checked = draft.kind === 'coreOutpoints' && draft.outpoints.includes(key) + return ( + toggleCoreOutpoint(key, next)}> + + + + ) + })} +
+ )} + + {mode === CoinControlMode.Inputs && sourceKind === SourceKind.PlatformAddress && ( +
+ Up to {PLATFORM_INPUT_LIMIT} inputs. Set the maximum credits available from each. + {platformAddresses.length === 0 && } + {platformAddresses.map(entry => { + const selected = selectedPlatformInputs.find(input => input.address === entry.platformAddress) + const full = selectedPlatformInputs.length >= PLATFORM_INPUT_LIMIT + const invalid = selected != null && (selected.credits <= 0n || selected.credits > entry.balanceCredits) + return ( +
+ togglePlatformInput(entry, checked)}> + + } /> + + {selected && ( +
+ + +
+ )} +
+ ) + })} +
+ )} + + {mode === CoinControlMode.Inputs && sourceKind === SourceKind.Shielded && ( +
+ Choose up to {SHIELDED_NOTE_LIMIT} notes. + {shieldedNotes.length === 0 && } + {shieldedNotes.map(note => { + const picked = draft.kind === 'shieldedNotes' ? draft.noteIndexes : [] + const checked = picked.includes(note.index) + const full = picked.length >= SHIELDED_NOTE_LIMIT + return ( + toggleShieldedNote(note.index, next)}> + + · {note.address.slice(0, 14)}…} /> + + ) + })} +
+ )} + + )} +
+ +
+ {!fixed && ( + + )} + + +
+
+
, + document.body, + ) +} + +function Empty({text}: {text: string}): React.JSX.Element { + return
{text}
+} + +function AddressValue({address, detail}: {address: string; detail: React.ReactNode}): React.JSX.Element { + return ( + + {address} + {detail} + + ) +} + +function ChoiceRow({checked, onChange, children}: {checked: boolean; onChange: () => void; children: React.ReactNode}): React.JSX.Element { + return ( + + ) +} + +function CheckRow({checked, onChange, children, disabled = false, bare = false}: {checked: boolean; onChange: (checked: boolean) => void; children: React.ReactNode; disabled?: boolean; bare?: boolean}): React.JSX.Element { + let rowClass = '' + if (!bare) rowClass = `rounded-[.75rem] p-3 ${checked ? 'dash-block-accent-5' : 'dash-block'}` + return ( +
+ !disabled && onChange(next)} label={{children}} /> +
+ ) +} diff --git a/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx b/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx deleted file mode 100644 index 0a4ed471..00000000 --- a/src/renderer/src/components/pages/transfer/CoreUtxoPicker.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Text } from "@renderer/components/dash-ui-kit-enxtended"; -import { DashLogo } from "dash-ui-kit/react"; -import Checkbox from "@renderer/components/ui/Checkbox"; -import { SelectableUtxo } from "@renderer/api/types"; -import { davToDash, davToDashCompact } from "@renderer/utils/balance"; - -export const outpointKey = (utxo: {txid: string; vout: number}): string => `${utxo.txid}:${utxo.vout}` - -// TEST ONLY, to be reverted. -interface CoreUtxoPickerProps { - utxos: SelectableUtxo[] - picked: string[] - onToggle: (key: string, checked: boolean) => void - onClear: () => void -} - -export default function CoreUtxoPicker({utxos, picked, onToggle, onClear}: CoreUtxoPickerProps): React.JSX.Element { - const chosen = new Set(picked) - const total = utxos - .filter(utxo => chosen.has(outpointKey(utxo))) - .reduce((sum, utxo) => sum + utxo.satoshis, 0n) - - return ( -
-
- - Picked {picked.length}/{utxos.length} coins (test) - -
- {davToDash(total)} Dash - {picked.length > 0 && ( - - )} -
-
- -
- {utxos.length === 0 && ( - - No spendable coins - - )} - {utxos.map(utxo => { - const key = outpointKey(utxo) - const isPicked = chosen.has(key) - - return ( -
- onToggle(key, next)} - label={ -
- -
- - {utxo.txid.slice(0, 12)}…:{utxo.vout} - - - {davToDashCompact(utxo.satoshis)} Dash · {utxo.address.slice(0, 10)}… - {utxo.height === 0 && ' · pending'} - -
-
- } - /> -
- ) - })} -
- - - Picked coins are spent whole and override the address above; the fee and - the change come out of them. Pick nothing to let the wallet choose. - -
- ) -} diff --git a/src/renderer/src/components/pages/transfer/PlatformInputPicker.tsx b/src/renderer/src/components/pages/transfer/PlatformInputPicker.tsx deleted file mode 100644 index 0064b286..00000000 --- a/src/renderer/src/components/pages/transfer/PlatformInputPicker.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { Text, CreditsIcon } from "@renderer/components/dash-ui-kit-enxtended"; -import Checkbox from "@renderer/components/ui/Checkbox"; -import CreditsAmount from "@renderer/components/ui/CreditsAmount"; -import { PlatformAddressDto } from "@renderer/api/types"; - -interface PlatformInputPickerProps { - addresses: PlatformAddressDto[] - picked: string[] - onToggle: (platformAddress: string, checked: boolean) => void - onClear: () => void - feeAddress: string | null - onFeeAddressChange: (platformAddress: string) => void - feeCredits: bigint | null - maxInputs: number -} - -export default function PlatformInputPicker({ - addresses, picked, onToggle, onClear, feeAddress, onFeeAddressChange, feeCredits, maxInputs, -}: PlatformInputPickerProps): React.JSX.Element { - const chosen = new Set(picked) - const full = picked.length >= maxInputs - const total = addresses - .filter(entry => chosen.has(entry.platformAddress)) - .reduce((sum, entry) => sum + BigInt(entry.balanceCredits), 0n) - - return ( -
-
- - Picked {picked.length}/{maxInputs} - -
- - - - {picked.length > 0 && ( - - )} -
-
- -
- {addresses.length === 0 && ( - - No funded Platform addresses - - )} - {addresses.map(entry => { - const isPicked = chosen.has(entry.platformAddress) - const paysFee = isPicked && entry.platformAddress === feeAddress - const keptBack = paysFee && feeCredits !== null ? feeCredits : 0n - const short = paysFee && feeCredits !== null && BigInt(entry.balanceCredits) <= feeCredits - - return ( -
- (isPicked || !full) && onToggle(entry.platformAddress, next)} - label={ -
- -
- - {entry.platformAddress} - - - - {paysFee && ' after the fee'} - -
-
- } - /> - - {isPicked && ( - - )} -
- ) - })} -
- - - The amount is drawn from the addresses you pick, largest share first, and - whatever is not drawn stays where it is. The one paying keeps the fee back - out of its own balance, and a transition takes at most {maxInputs} of them. - -
- ) -} diff --git a/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx b/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx deleted file mode 100644 index d00faa1e..00000000 --- a/src/renderer/src/components/pages/transfer/ShieldedNotePicker.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Text, ShieldSmallIcon } from "@renderer/components/dash-ui-kit-enxtended"; -import Checkbox from "@renderer/components/ui/Checkbox"; -import CreditsAmount from "@renderer/components/ui/CreditsAmount"; -import { ShieldedNoteInfo } from "@renderer/api/types"; - -// TEST ONLY, to be reverted. -interface ShieldedNotePickerProps { - notes: ShieldedNoteInfo[] - picked: number[] - onToggle: (index: number, checked: boolean) => void - onClear: () => void - maxNotes: number -} - -export default function ShieldedNotePicker({notes, picked, onToggle, onClear, maxNotes}: ShieldedNotePickerProps): React.JSX.Element { - const chosen = new Set(picked) - const full = picked.length >= maxNotes - const total = notes - .filter(note => chosen.has(note.index)) - .reduce((sum, note) => sum + note.amount, 0n) - - return ( -
-
- - Picked {picked.length}/{maxNotes} notes (test) - -
- - - - {picked.length > 0 && ( - - )} -
-
- -
- {notes.length === 0 && ( - - No spendable notes — sync on the Shielded page - - )} - {notes.map(note => { - const isPicked = chosen.has(note.index) - - return ( -
- (isPicked || !full) && onToggle(note.index, next)} - label={ -
- -
- - note #{note.index} - - - · {note.address.slice(0, 14)}… - -
-
- } - /> -
- ) - })} -
- - - Picked notes are spent whole and override the address above. A bundle - fits {maxNotes} actions, and every note spent takes one of them. - -
- ) -} diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index ffc8df82..1ed8f9e2 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -1,20 +1,17 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { DashLogo } from "dash-ui-kit/react"; -import { Text, ShieldSmallIcon } from "@renderer/components/dash-ui-kit-enxtended"; +import { Text, ShieldSmallIcon, SettingsIcon } from "@renderer/components/dash-ui-kit-enxtended"; import P2pSyncAlert from "@renderer/components/ui/P2pSyncAlert"; import ShieldedNotesAlert from "@renderer/components/ui/ShieldedNotesAlert"; import CreditsAmount from "@renderer/components/ui/CreditsAmount"; import Checkbox from "@renderer/components/ui/Checkbox"; -import PlatformInputPicker from "./PlatformInputPicker"; import PlatformRecipientsTest from "./PlatformRecipientsTest"; import CoreRecipientsTest from "./CoreRecipientsTest"; import ShieldedRecipientsTest from "./ShieldedRecipientsTest"; -import ShieldedNotePicker from "./ShieldedNotePicker"; -import CoreUtxoPicker, { outpointKey } from "./CoreUtxoPicker"; -import { PLATFORM_INPUT_LIMIT, PLATFORM_RECIPIENT_LIMIT } from "@renderer/constants/platform"; +import { PLATFORM_RECIPIENT_LIMIT } from "@renderer/constants/platform"; import { CORE_RECIPIENT_LIMIT } from "@renderer/constants/core"; -import { SHIELDED_NOTE_LIMIT, SHIELDED_RECIPIENT_LIMIT } from "@renderer/constants/shielded"; +import { SHIELDED_RECIPIENT_LIMIT } from "@renderer/constants/shielded"; import ProverPill from "@renderer/components/pages/shielded/ProverPill"; import Spinner from "@renderer/components/ui/Spinner"; import { useAuth } from "@renderer/contexts/AuthContext"; @@ -34,12 +31,15 @@ import { isLikelyShieldedAddress } from "@renderer/utils/shieldedAddress"; import { shieldedBalancesByAddress } from "@renderer/utils/shieldedBalances"; import { amountErrorFor } from "@renderer/utils/amountValidation"; import { isUnfinishedAssetLockFunding } from "@renderer/utils/identityRegistration"; -import { - specificSourceKindForOperation, - updateSpecificSourceAddress, - updateSpecificSourceEnabled, -} from "@renderer/utils/specificSource"; import { clearSendDraft, getOrCreateSendDraft, saveSendDraft } from "@renderer/utils/sendDraft"; +import { + automaticCoinControl, + normalizeCoinControlSelection, + outpointKey, + toCoreSpendSource, + toPlatformSpendSource, + toShieldedSpendSource, +} from "@renderer/utils/coinControl"; import { DESTINATION_KINDS, resolveOperation, @@ -57,17 +57,16 @@ import { ShieldedSpendPhase } from "@renderer/enums/ShieldedSpendPhase"; import { AssetLockFundingPhase } from "@renderer/enums/AssetLockFundingPhase"; import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; -import { AssetLockFundingState, CoreSpendSource, PlatformAddressDto, PlatformSpendSource, SelectableUtxo, ShieldedSpendSource, ShieldedSpendState } from "@renderer/api/types"; +import { AssetLockFundingState, PlatformAddressDto, SelectableUtxo, ShieldedNoteInfo, ShieldedSpendState, WalletAddressDto } from "@renderer/api/types"; import type { SendDraft } from "@renderer/types/SendDraft"; -import type { SpecificSourcePreferences } from "@renderer/types/SpecificSource"; +import type { CoinControlSelection } from "@renderer/types/CoinControl"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; import AmountField from "./AmountField"; import AmountSlider from "./AmountSlider"; import TransferWizard from "./TransferWizard"; import RecipientInput from "./RecipientInput"; import { SourcePicker, DestinationPicker } from "./EndpointPicker"; -import CoreAddressSelect from "@renderer/components/pages/receive/CoreAddressSelect"; -import ShieldedAddressSelect from "./ShieldedAddressSelect"; +import CoinControlModal from "./CoinControlModal"; import TransferConfirmModal from "@renderer/components/modal/TransferConfirmModal"; import AssetLockFundingModal from "@renderer/components/modal/AssetLockFundingModal"; import SendConfirmModal from "@renderer/components/modal/SendConfirmModal"; @@ -90,7 +89,7 @@ function WalletTransferHub(): React.JSX.Element { const [draft, setDraftState] = useState(() => getOrCreateSendDraft(walletId, searchParams.get('from'), searchParams.get('to'))) const draftRef = useRef(draft) - const { fromKind, toKind, fromAddress, fromIdentity, toValue, amount, acked, specificSourcePreferences } = draft + const { fromKind, toKind, fromAddress, fromIdentity, toValue, amount, acked } = draft const updateDraft = (update: (current: SendDraft) => SendDraft): void => { const next = update(draftRef.current) draftRef.current = next @@ -104,20 +103,12 @@ function WalletTransferHub(): React.JSX.Element { const setToValue = (toValue: string): void => updateDraft(current => ({ ...current, toValue })) const setAmount = (amount: string): void => updateDraft(current => ({ ...current, amount })) const setAcked = (acked: boolean): void => updateDraft(current => ({ ...current, acked })) - const setSpecificSourcePreferences = ( - update: (current: SpecificSourcePreferences) => SpecificSourcePreferences, - ): void => updateDraft(current => ({ - ...current, - specificSourcePreferences: update(current.specificSourcePreferences), - })) const [testRecipients, setTestRecipients] = useState([]) const [testCoreRecipients, setTestCoreRecipients] = useState([]) const [testShieldedRecipients, setTestShieldedRecipients] = useState([]) - const [pickedNoteIndexes, setPickedNoteIndexes] = useState([]) const [utxos, setUtxos] = useState([]) - const [pickedOutpoints, setPickedOutpoints] = useState([]) - const [pickedPlatformInputs, setPickedPlatformInputs] = useState([]) - const [platformFeeAddress, setPlatformFeeAddress] = useState(null) + const [coinControl, setCoinControl] = useState(automaticCoinControl) + const [coinControlOpen, setCoinControlOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) const [notesUnlockOpen, setNotesUnlockOpen] = useState(false) const [wizardKey, setWizardKey] = useState(0) @@ -178,9 +169,6 @@ function WalletTransferHub(): React.JSX.Element { const reason = unsupportedReason(fromKind, toKind) const info = operation ? operationInfo(operation) : null const shieldedInvolved = fromKind === SourceKind.Shielded || toKind === DestinationKind.Shielded - const specificSourceKind = specificSourceKindForOperation(operation) - const useSpecificSource = specificSourcePreferences.enabled - const destinationKinds = useMemo( () => DESTINATION_KINDS.filter(d => d.kind !== DestinationKind.NewIdentity && resolveOperation(fromKind, d.kind) != null), [fromKind], @@ -216,110 +204,87 @@ function WalletTransferHub(): React.JSX.Element { .sort((a, b) => (a.balance < b.balance ? 1 : a.balance > b.balance ? -1 : 0)), [receiving, change], ) - const selectedCoreAddress = coreAddresses.find(a => a.address === specificSourcePreferences.addresses[SourceKind.Core]) ?? coreAddresses[0] - const coreSpecificAddress = specificSourceKind === SourceKind.Core && useSpecificSource ? selectedCoreAddress : undefined - const corePicking = specificSourceKind === SourceKind.Core && useSpecificSource - const pickedUtxos = useMemo( - () => (corePicking ? utxos.filter(utxo => pickedOutpoints.includes(outpointKey(utxo))) : []), - [corePicking, utxos, pickedOutpoints], - ) - // A pick names the coins themselves, which is the only way a send reaches for - // ones an amount would have stopped short of. - const coreSpendSource: CoreSpendSource | undefined = pickedUtxos.length > 0 - ? { kind: 'outpoints', outpoints: pickedUtxos.map(utxo => ({ txid: utxo.txid, vout: utxo.vout })) } - : coreSpecificAddress - ? { kind: 'address', address: coreSpecificAddress.address } - : undefined - const spendableNotes = useMemo( () => (shieldedSync.phase === ShieldedSyncPhase.Done ? shieldedSync.notes.filter(n => !n.spent) : []) .slice() .sort((a, b) => (BigInt(a.amount) < BigInt(b.amount) ? 1 : BigInt(a.amount) > BigInt(b.amount) ? -1 : 0)), [shieldedSync.phase, shieldedSync.notes], ) - const shieldedSpendOperation = operation === TransferOperation.ShieldedTransfer || operation === TransferOperation.Unshield || operation === TransferOperation.ShieldedWithdrawal const notesSyncing = shieldedSync.phase === ShieldedSyncPhase.Syncing || shieldedSync.phase === ShieldedSyncPhase.Recovering const shieldedAddressBalances = useMemo(() => shieldedBalancesByAddress(spendableNotes), [spendableNotes]) - const shieldedAddresses = useMemo(() => [...shieldedAddressBalances.keys()], [shieldedAddressBalances]) - const shieldedFromAddress = specificSourcePreferences.addresses[SourceKind.Shielded] - const selectedShieldedAddress = shieldedFromAddress != null && shieldedAddresses.includes(shieldedFromAddress) - ? shieldedFromAddress - : shieldedAddresses[0] - const shieldedPicking = shieldedSpendOperation && useSpecificSource - const pickedNotes = useMemo( - () => (shieldedPicking ? spendableNotes.filter(n => pickedNoteIndexes.includes(n.index)) : []), - [shieldedPicking, spendableNotes, pickedNoteIndexes], - ) - const shieldedSpecificNotes = useMemo( - () => pickedNotes.length > 0 ? pickedNotes - : shieldedPicking && selectedShieldedAddress != null - ? spendableNotes.filter(n => n.address === selectedShieldedAddress) - : undefined, - [pickedNotes, shieldedPicking, selectedShieldedAddress, spendableNotes], - ) - // A pick names the notes themselves, which is the only way a spend reaches - // for ones an amount would have stopped short of. - const shieldedSpendSource = useMemo( - (): ShieldedSpendSource | undefined => shieldedSpecificNotes == null - ? undefined - : { - kind: pickedNotes.length > 0 ? 'notes' : 'address', - noteIndexes: shieldedSpecificNotes.map(note => note.index), - }, - [shieldedSpecificNotes, pickedNotes], + const coinControlInventory = useMemo(() => ({ + coreAddresses: coreAddresses.map(address => address.address), + coreOutpoints: utxos.map(outpointKey), + platformBalances: Object.fromEntries(fundedAddresses.map(address => [address.platformAddress, address.balanceCredits])), + shieldedAddresses: [...shieldedAddressBalances.keys()], + shieldedNoteIndexes: spendableNotes.map(note => note.index), + }), [coreAddresses, utxos, fundedAddresses, shieldedAddressBalances, spendableNotes]) + const appliedCoinControl = useMemo( + () => normalizeCoinControlSelection(coinControl, operation, coinControlInventory), + [coinControl, operation, coinControlInventory], ) - const platformPicking = specificSourceKind === SourceKind.PlatformAddress && useSpecificSource - const pickedPlatformAddresses = useMemo( - () => fundedAddresses.filter(a => pickedPlatformInputs.includes(a.platformAddress)), - [fundedAddresses, pickedPlatformInputs], - ) - // Consensus charges one input, so a pick that lost its payer falls back to the - // address most likely to keep the fee back. - const platformFeePayer = pickedPlatformAddresses.some(a => a.platformAddress === platformFeeAddress) - ? platformFeeAddress - : pickedPlatformAddresses.reduce( - (best, a) => (best == null || BigInt(a.balanceCredits) > BigInt(best.balanceCredits) ? a : best), - undefined, - )?.platformAddress ?? null + useEffect(() => { + setCoinControl(automaticCoinControl()) + }, [operation]) - // Consensus refuses an output address that is also an input, so a transfer - // back into what funds it is caught before the amount step. - const fundingAddresses = platformPicking - ? pickedPlatformInputs - : selectedSource ? [selectedSource.platformAddress] : [] + useEffect(() => { + if (appliedCoinControl !== coinControl) setCoinControl(appliedCoinControl) + }, [appliedCoinControl, coinControl]) - // A pick names the addresses to draw on and the one that pays; how much each - // puts in is the backend's to allocate. - const platformSource: PlatformSpendSource | null = useMemo( - () => { - if (specificSourceKind !== SourceKind.PlatformAddress) return null - if (platformPicking) { - if (pickedPlatformAddresses.length === 0 || platformFeePayer == null) return null - return { - kind: 'inputs', - inputs: pickedPlatformAddresses.map(a => ({ address: a.platformAddress, credits: BigInt(a.balanceCredits) })), - feeStrategy: [{ kind: 'deductFromInput', address: platformFeePayer }], - } - } - return selectedSource ? { kind: 'address', address: selectedSource.platformAddress } : null - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [specificSourceKind, platformPicking, pickedPlatformAddresses, platformFeePayer, selectedSource?.platformAddress], + const coreSpendSource = useMemo(() => toCoreSpendSource(appliedCoinControl, utxos), [appliedCoinControl, utxos]) + const platformSource = useMemo(() => toPlatformSpendSource(appliedCoinControl), [appliedCoinControl]) + const shieldedSpendSource = useMemo( + () => toShieldedSpendSource(appliedCoinControl, spendableNotes), + [appliedCoinControl, spendableNotes], ) + let pickedUtxos: SelectableUtxo[] = [] + if (appliedCoinControl.kind === 'coreOutpoints') { + pickedUtxos = utxos.filter(utxo => appliedCoinControl.outpoints.includes(outpointKey(utxo))) + } + let coreSpecificAddress: WalletAddressDto | undefined + if (appliedCoinControl.kind === 'coreAddress') { + coreSpecificAddress = coreAddresses.find(address => address.address === appliedCoinControl.address) + } + let selectedShieldedNotes: ShieldedNoteInfo[] | null = null + if (appliedCoinControl.kind === 'shieldedNotes') { + selectedShieldedNotes = spendableNotes.filter(note => appliedCoinControl.noteIndexes.includes(note.index)) + } else if (appliedCoinControl.kind === 'shieldedAddress') { + selectedShieldedNotes = spendableNotes.filter(note => note.address === appliedCoinControl.address) + } + let fundingAddresses = fundedAddresses.map(address => address.platformAddress) + if (appliedCoinControl.kind === 'platformInputs') { + fundingAddresses = appliedCoinControl.inputs.map(input => input.address) + } else if (appliedCoinControl.kind === 'platformAddress') { + fundingAddresses = [appliedCoinControl.address] + } - const balanceDuffs = pickedUtxos.length > 0 - ? pickedUtxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) - : coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount + let balanceDuffs = balance.dash.amount + if (pickedUtxos.length > 0) { + balanceDuffs = pickedUtxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) + } else if (coreSpecificAddress) { + balanceDuffs = coreSpecificAddress.balance + } const shieldedBalance = shieldedSync.phase === ShieldedSyncPhase.Done && shieldedSync.balance !== null ? BigInt(shieldedSync.balance) : null - const availableCredits: bigint | null = - fromKind === SourceKind.PlatformAddress ? (platformPicking && pickedPlatformAddresses.length > 0 - ? pickedPlatformAddresses.reduce((sum, a) => sum + BigInt(a.balanceCredits), 0n) - : selectedSource ? BigInt(selectedSource.balanceCredits) : 0n) - : fromKind === SourceKind.Identity ? (selectedIdentity ? BigInt(String(selectedIdentity.balance.amount)) : 0n) - : fromKind === SourceKind.Shielded ? (shieldedSpecificNotes != null ? shieldedSpecificNotes.reduce((sum, n) => sum + BigInt(n.amount), 0n) : shieldedBalance) - : null + let availableCredits: bigint | null = null + if (fromKind === SourceKind.PlatformAddress) { + if (operation === TransferOperation.Shield) { + availableCredits = selectedSource?.balanceCredits ?? 0n + } else if (appliedCoinControl.kind === 'platformInputs') { + availableCredits = appliedCoinControl.inputs.reduce((sum, input) => sum + input.credits, 0n) + } else if (appliedCoinControl.kind === 'platformAddress') { + availableCredits = fundedAddresses.find(address => address.platformAddress === appliedCoinControl.address)?.balanceCredits ?? 0n + } else { + availableCredits = fundedAddresses.reduce((sum, address) => sum + address.balanceCredits, 0n) + } + } else if (fromKind === SourceKind.Identity) { + availableCredits = selectedIdentity ? BigInt(String(selectedIdentity.balance.amount)) : 0n + } else if (fromKind === SourceKind.Shielded) { + availableCredits = selectedShieldedNotes == null + ? shieldedBalance + : selectedShieldedNotes.reduce((sum, note) => sum + note.amount, 0n) + } const isCoreOperation = fromKind === SourceKind.Core const amountDuffs = useMemo(() => dashToDuffs(amount), [amount]) @@ -514,15 +479,40 @@ function WalletTransferHub(): React.JSX.Element { noteLimit, }) const fieldError = amountError ?? feeErr + let coinControlSummary = 'Automatic' + switch (appliedCoinControl.kind) { + case 'coreAddress': + coinControlSummary = 'One Core address' + break + case 'coreOutpoints': + coinControlSummary = appliedCoinControl.outpoints.length === 1 ? '1 coin' : `${appliedCoinControl.outpoints.length} coins` + break + case 'platformAddress': + coinControlSummary = 'One Platform address' + break + case 'platformInputs': + coinControlSummary = appliedCoinControl.inputs.length === 1 ? '1 input' : `${appliedCoinControl.inputs.length} inputs` + break + case 'shieldedAddress': + coinControlSummary = 'One shielded address' + break + case 'shieldedNotes': + coinControlSummary = appliedCoinControl.noteIndexes.length === 1 ? '1 note' : `${appliedCoinControl.noteIndexes.length} notes` + break + case 'automatic': + if (operation === TransferOperation.Shield) { + coinControlSummary = 'Fixed address' + } else if (fromKind === SourceKind.Identity) { + coinControlSummary = 'Fixed identity' + } + break + } const resetForm = (): void => { - setPickedPlatformInputs([]) - setPlatformFeeAddress(null) + setCoinControl(automaticCoinControl()) setTestRecipients([]) setTestCoreRecipients([]) setTestShieldedRecipients([]) - setPickedNoteIndexes([]) - setPickedOutpoints([]) const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false } draftRef.current = resetDraft setDraftState(resetDraft) @@ -538,102 +528,41 @@ function WalletTransferHub(): React.JSX.Element { <> { setFromKind(k); setAcked(false) }} + onKindChange={k => { setFromKind(k); setAcked(false); setCoinControl(automaticCoinControl()) }} platformAddresses={fundedAddresses} selectedPlatformAddress={selectedSource} onPlatformAddressChange={setFromAddress} - showPlatformAddress={!platformPicking} + showPlatformAddress={operation === TransferOperation.Shield} identities={identities} selectedIdentity={selectedIdentity} onIdentityChange={setFromIdentity} /> - {specificSourceKind != null && ( -
- setSpecificSourcePreferences(current => - updateSpecificSourceEnabled(current, enabled))} - label={ - - {specificSourceKind === SourceKind.PlatformAddress - ? 'Choose which addresses fund this' - : 'Send from a specific address'} - - } - /> - {useSpecificSource && specificSourceKind === SourceKind.Core && ( - <> - setSpecificSourcePreferences(current => - updateSpecificSourceAddress(current, SourceKind.Core, address))} - /> - setPickedOutpoints(current => - checked ? [...current, key] : current.filter(entry => entry !== key))} - onClear={() => setPickedOutpoints([])} - /> - - )} - {operation === TransferOperation.CoreSend && ( - - )} - {operation === TransferOperation.AddressFundsTransfer && ( - - )} - {platformPicking && ( - setPickedPlatformInputs(current => - checked ? [...current, address] : current.filter(entry => entry !== address))} - onClear={() => setPickedPlatformInputs([])} - feeAddress={platformFeePayer} - onFeeAddressChange={setPlatformFeeAddress} - feeCredits={feeCredits} - maxInputs={PLATFORM_INPUT_LIMIT} - /> - )} - {operation === TransferOperation.ShieldedTransfer && ( - - )} - {useSpecificSource && shieldedSpendOperation && ( - <> - setSpecificSourcePreferences(current => - updateSpecificSourceAddress(current, SourceKind.Shielded, address))} - /> - setPickedNoteIndexes(current => - checked ? [...current, index] : current.filter(entry => entry !== index))} - onClear={() => setPickedNoteIndexes([])} - maxNotes={SHIELDED_NOTE_LIMIT} - /> - setNotesUnlockOpen(true)} syncing={notesSyncing} /> - - )} -
+ {operation != null && ( + + )} + + {operation === TransferOperation.CoreSend && ( + + )} + {operation === TransferOperation.AddressFundsTransfer && ( + + )} + {operation === TransferOperation.ShieldedTransfer && ( + + )} + {fromKind === SourceKind.Shielded && ( + setNotesUnlockOpen(true)} syncing={notesSyncing} /> )} {toKind === DestinationKind.CoreAddress && operation === TransferOperation.CoreSend ? ( @@ -819,11 +748,30 @@ function WalletTransferHub(): React.JSX.Element {
) - const fromDisplay = - fromKind === SourceKind.Core ? 'Dash Core (L1)' - : fromKind === SourceKind.PlatformAddress ? (selectedSource?.platformAddress ?? '') - : fromKind === SourceKind.Identity ? (selectedIdentity?.identifier ?? '') - : 'Your shielded balance' + let fromDisplay = 'Your shielded balance' + switch (fromKind) { + case SourceKind.Core: + fromDisplay = 'Dash Core (L1)' + break + case SourceKind.PlatformAddress: + if (operation === TransferOperation.Shield) { + fromDisplay = selectedSource?.platformAddress ?? '' + } else if (appliedCoinControl.kind === 'platformAddress') { + fromDisplay = appliedCoinControl.address + } else if (appliedCoinControl.kind === 'platformInputs') { + if (appliedCoinControl.inputs.length === 1) { + fromDisplay = '1 Platform input' + } else { + fromDisplay = `${appliedCoinControl.inputs.length} Platform inputs` + } + } else { + fromDisplay = 'Automatic Platform selection' + } + break + case SourceKind.Identity: + fromDisplay = selectedIdentity?.identifier ?? '' + break + } const toDisplay = toKind === DestinationKind.NewIdentity ? 'New identity' : trimmedTo @@ -1014,6 +962,21 @@ function WalletTransferHub(): React.JSX.Element { submitDisabled={!canSubmit} /> + setCoinControlOpen(false)} + onApply={setCoinControl} + /> + {operation === TransferOperation.CoreSend && ( ; feeAddress: string } + | { kind: 'shieldedAddress'; address: string } + | { kind: 'shieldedNotes'; noteIndexes: number[] } + +export interface CoinControlInventory { + coreAddresses: string[] + coreOutpoints: string[] + platformBalances: Record + shieldedAddresses: string[] + shieldedNoteIndexes: number[] +} diff --git a/src/renderer/src/types/SendDraft.ts b/src/renderer/src/types/SendDraft.ts index c12dc4d9..8393ac8b 100644 --- a/src/renderer/src/types/SendDraft.ts +++ b/src/renderer/src/types/SendDraft.ts @@ -1,6 +1,5 @@ import { DestinationKind } from '../enums/DestinationKind' import { SourceKind } from '../enums/SourceKind' -import type { SpecificSourcePreferences } from './SpecificSource' export interface SendDraft { fromKind: SourceKind @@ -10,5 +9,4 @@ export interface SendDraft { toValue: string amount: string acked: boolean - specificSourcePreferences: SpecificSourcePreferences } diff --git a/src/renderer/src/types/SpecificSource.ts b/src/renderer/src/types/SpecificSource.ts deleted file mode 100644 index 807253a8..00000000 --- a/src/renderer/src/types/SpecificSource.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { SourceKind } from '../enums/SourceKind' - -export type SpecificSourceKind = SourceKind.Core | SourceKind.PlatformAddress | SourceKind.Shielded - -export interface SpecificSourcePreferences { - enabled: boolean - addresses: Record -} diff --git a/src/renderer/src/utils/coinControl.ts b/src/renderer/src/utils/coinControl.ts new file mode 100644 index 00000000..8123c89c --- /dev/null +++ b/src/renderer/src/utils/coinControl.ts @@ -0,0 +1,138 @@ +import type { + CoreSpendSource, + PlatformSpendSource, + SelectableUtxo, + ShieldedNoteInfo, + ShieldedSpendSource, +} from '../api/types' +import { PLATFORM_INPUT_LIMIT } from '../constants/platform' +import { SHIELDED_NOTE_LIMIT } from '../constants/shielded' +import { SourceKind } from '../enums/SourceKind' +import { TransferOperation } from '../enums/TransferOperation' +import type { CoinControlInventory, CoinControlSelection } from '../types/CoinControl' + +export const automaticCoinControl = (): CoinControlSelection => ({kind: 'automatic'}) + +export function coinControlSourceKind(operation: TransferOperation | null): SourceKind | null { + if ( + operation === TransferOperation.CoreSend + || operation === TransferOperation.AssetLockFunding + || operation === TransferOperation.AssetLockShield + || operation === TransferOperation.IdentityRegister + || operation === TransferOperation.IdentityTopUpL1 + ) return SourceKind.Core + + if ( + operation === TransferOperation.AddressFundsTransfer + || operation === TransferOperation.AddressWithdrawal + || operation === TransferOperation.IdentityCreate + || operation === TransferOperation.IdentityTopUp + ) return SourceKind.PlatformAddress + + if ( + operation === TransferOperation.ShieldedTransfer + || operation === TransferOperation.Unshield + || operation === TransferOperation.ShieldedWithdrawal + ) return SourceKind.Shielded + + return null +} + +export function normalizeCoinControlSelection( + selection: CoinControlSelection, + operation: TransferOperation | null, + inventory: CoinControlInventory, +): CoinControlSelection { + const sourceKind = coinControlSourceKind(operation) + if (selection.kind === 'automatic') return selection + + if (selection.kind === 'coreAddress') { + return sourceKind === SourceKind.Core && inventory.coreAddresses.includes(selection.address) + ? selection + : automaticCoinControl() + } + if (selection.kind === 'coreOutpoints') { + const available = new Set(inventory.coreOutpoints) + return sourceKind === SourceKind.Core + && selection.outpoints.length > 0 + && new Set(selection.outpoints).size === selection.outpoints.length + && selection.outpoints.every(outpoint => available.has(outpoint)) + ? selection + : automaticCoinControl() + } + if (selection.kind === 'platformAddress') { + return sourceKind === SourceKind.PlatformAddress && inventory.platformBalances[selection.address] != null + ? selection + : automaticCoinControl() + } + if (selection.kind === 'platformInputs') { + const addresses = selection.inputs.map(input => input.address) + const valid = sourceKind === SourceKind.PlatformAddress + && selection.inputs.length > 0 + && selection.inputs.length <= PLATFORM_INPUT_LIMIT + && new Set(addresses).size === addresses.length + && addresses.includes(selection.feeAddress) + && selection.inputs.every(input => { + const balance = inventory.platformBalances[input.address] + return balance != null && input.credits > 0n && input.credits <= balance + }) + return valid ? selection : automaticCoinControl() + } + if (selection.kind === 'shieldedAddress') { + return sourceKind === SourceKind.Shielded && inventory.shieldedAddresses.includes(selection.address) + ? selection + : automaticCoinControl() + } + + const available = new Set(inventory.shieldedNoteIndexes) + return sourceKind === SourceKind.Shielded + && selection.noteIndexes.length > 0 + && selection.noteIndexes.length <= SHIELDED_NOTE_LIMIT + && new Set(selection.noteIndexes).size === selection.noteIndexes.length + && selection.noteIndexes.every(index => available.has(index)) + ? selection + : automaticCoinControl() +} + +export function toCoreSpendSource( + selection: CoinControlSelection, + utxos: SelectableUtxo[], +): CoreSpendSource | undefined { + if (selection.kind === 'coreAddress') return {kind: 'address', address: selection.address} + if (selection.kind !== 'coreOutpoints') return undefined + + const selected = new Set(selection.outpoints) + return { + kind: 'outpoints', + outpoints: utxos + .filter(utxo => selected.has(outpointKey(utxo))) + .map(utxo => ({txid: utxo.txid, vout: utxo.vout})), + } +} + +export function toPlatformSpendSource(selection: CoinControlSelection): PlatformSpendSource | null { + if (selection.kind === 'platformAddress') return {kind: 'address', address: selection.address} + if (selection.kind !== 'platformInputs') return null + return { + kind: 'inputs', + inputs: selection.inputs, + feeStrategy: [{kind: 'deductFromInput', address: selection.feeAddress}], + } +} + +export function toShieldedSpendSource( + selection: CoinControlSelection, + notes: ShieldedNoteInfo[], +): ShieldedSpendSource | undefined { + if (selection.kind === 'shieldedNotes') { + return {kind: 'notes', noteIndexes: selection.noteIndexes} + } + if (selection.kind !== 'shieldedAddress') return undefined + return { + kind: 'address', + noteIndexes: notes.filter(note => note.address === selection.address).map(note => note.index), + } +} + +export const outpointKey = (outpoint: {txid: string; vout: number}): string => + `${outpoint.txid}:${outpoint.vout}` diff --git a/src/renderer/src/utils/sendDraft.ts b/src/renderer/src/utils/sendDraft.ts index 2f6bd3f7..956d5ee6 100644 --- a/src/renderer/src/utils/sendDraft.ts +++ b/src/renderer/src/utils/sendDraft.ts @@ -1,7 +1,6 @@ import { DestinationKind } from '../enums/DestinationKind' import { SourceKind } from '../enums/SourceKind' import type { SendDraft } from '../types/SendDraft' -import { initialSpecificSourcePreferences } from './specificSource' const sendDrafts = new Map() @@ -22,7 +21,6 @@ export function createSendDraft(from: string | null = null, to: string | null = toValue: '', amount: '', acked: false, - specificSourcePreferences: initialSpecificSourcePreferences(), } } diff --git a/src/renderer/src/utils/specificSource.ts b/src/renderer/src/utils/specificSource.ts deleted file mode 100644 index 997bf786..00000000 --- a/src/renderer/src/utils/specificSource.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { SourceKind } from '../enums/SourceKind' -import { TransferOperation } from '../enums/TransferOperation' -import type { - SpecificSourceKind, - SpecificSourcePreferences, -} from '../types/SpecificSource' - -export function initialSpecificSourcePreferences(): SpecificSourcePreferences { - return { - enabled: false, - addresses: { - [SourceKind.Core]: null, - [SourceKind.PlatformAddress]: null, - [SourceKind.Shielded]: null, - }, - } -} - -export function specificSourceKindForOperation(operation: TransferOperation | null): SpecificSourceKind | null { - // Every operation funded by L1 coins, not just the plain send: an asset lock - // binds the coins it spends to its L2 destination for good. - if ( - operation === TransferOperation.CoreSend - || operation === TransferOperation.AssetLockFunding - || operation === TransferOperation.AssetLockShield - || operation === TransferOperation.IdentityRegister - || operation === TransferOperation.IdentityTopUpL1 - ) { - return SourceKind.Core - } - // The three transitions whose fee scales with the inputs they take, which are - // the only ones a pick can name. - if ( - operation === TransferOperation.AddressFundsTransfer - || operation === TransferOperation.AddressWithdrawal - || operation === TransferOperation.IdentityCreate - || operation === TransferOperation.IdentityTopUp - ) { - return SourceKind.PlatformAddress - } - if ( - operation === TransferOperation.ShieldedTransfer - || operation === TransferOperation.Unshield - || operation === TransferOperation.ShieldedWithdrawal - ) { - return SourceKind.Shielded - } - return null -} - -export function updateSpecificSourceEnabled( - preferences: SpecificSourcePreferences, - enabled: boolean, -): SpecificSourcePreferences { - return { ...preferences, enabled } -} - -export function updateSpecificSourceAddress( - preferences: SpecificSourcePreferences, - kind: SpecificSourceKind, - address: string, -): SpecificSourcePreferences { - return { - ...preferences, - addresses: { ...preferences.addresses, [kind]: address }, - } -} diff --git a/tests/unit/coinControl.test.ts b/tests/unit/coinControl.test.ts new file mode 100644 index 00000000..bc226dda --- /dev/null +++ b/tests/unit/coinControl.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { PLATFORM_INPUT_LIMIT } from '../../src/renderer/src/constants/platform' +import { SHIELDED_NOTE_LIMIT } from '../../src/renderer/src/constants/shielded' +import { SourceKind } from '../../src/renderer/src/enums/SourceKind' +import { TransferOperation } from '../../src/renderer/src/enums/TransferOperation' +import type { CoinControlInventory, CoinControlSelection } from '../../src/renderer/src/types/CoinControl' +import { + automaticCoinControl, + coinControlSourceKind, + normalizeCoinControlSelection, + toCoreSpendSource, + toPlatformSpendSource, + toShieldedSpendSource, +} from '../../src/renderer/src/utils/coinControl' + +const inventory: CoinControlInventory = { + coreAddresses: ['core-a'], + coreOutpoints: ['tx-a:0', 'tx-b:1'], + platformBalances: {'platform-a': 5_000_000n, 'platform-b': 7_000_000n}, + shieldedAddresses: ['shielded-a'], + shieldedNoteIndexes: [4, 8], +} + +describe('coin control', () => { + it.each([ + [TransferOperation.CoreSend, SourceKind.Core], + [TransferOperation.AssetLockFunding, SourceKind.Core], + [TransferOperation.AssetLockShield, SourceKind.Core], + [TransferOperation.IdentityRegister, SourceKind.Core], + [TransferOperation.IdentityTopUpL1, SourceKind.Core], + [TransferOperation.AddressFundsTransfer, SourceKind.PlatformAddress], + [TransferOperation.AddressWithdrawal, SourceKind.PlatformAddress], + [TransferOperation.IdentityCreate, SourceKind.PlatformAddress], + [TransferOperation.IdentityTopUp, SourceKind.PlatformAddress], + [TransferOperation.ShieldedTransfer, SourceKind.Shielded], + [TransferOperation.Unshield, SourceKind.Shielded], + [TransferOperation.ShieldedWithdrawal, SourceKind.Shielded], + [TransferOperation.IdentityToAddress, null], + [TransferOperation.IdentityToIdentity, null], + [TransferOperation.IdentityWithdrawal, null], + [TransferOperation.Shield, null], + [TransferOperation.IdentityCreateFromShielded, null], + ])('maps %s to the source selection supported by its backend contract', (operation, expected) => { + expect(coinControlSourceKind(operation)).toBe(expected) + }) + + it('converts Core address and outpoint selections', () => { + expect(toCoreSpendSource({kind: 'coreAddress', address: 'core-a'}, [])).toEqual({ + kind: 'address', + address: 'core-a', + }) + expect(toCoreSpendSource({kind: 'coreOutpoints', outpoints: ['tx-b:1']}, [ + {txid: 'tx-a', vout: 0, satoshis: 1n, address: 'core-a', height: 1}, + {txid: 'tx-b', vout: 1, satoshis: 2n, address: 'core-a', height: 1}, + ])).toEqual({kind: 'outpoints', outpoints: [{txid: 'tx-b', vout: 1}]}) + }) + + it('converts Platform inputs with their caps and fee payer', () => { + expect(toPlatformSpendSource({ + kind: 'platformInputs', + inputs: [ + {address: 'platform-a', credits: 2_000_000n}, + {address: 'platform-b', credits: 3_000_000n}, + ], + feeAddress: 'platform-b', + })).toEqual({ + kind: 'inputs', + inputs: [ + {address: 'platform-a', credits: 2_000_000n}, + {address: 'platform-b', credits: 3_000_000n}, + ], + feeStrategy: [{kind: 'deductFromInput', address: 'platform-b'}], + }) + }) + + it('converts a shielded address to its notes and preserves explicit notes', () => { + const notes = [ + {index: 4, amount: 1n, spent: false, address: 'shielded-a'}, + {index: 8, amount: 2n, spent: false, address: 'shielded-a'}, + {index: 9, amount: 3n, spent: false, address: 'shielded-b'}, + ] + expect(toShieldedSpendSource({kind: 'shieldedAddress', address: 'shielded-a'}, notes)).toEqual({ + kind: 'address', + noteIndexes: [4, 8], + }) + expect(toShieldedSpendSource({kind: 'shieldedNotes', noteIndexes: [8]}, notes)).toEqual({ + kind: 'notes', + noteIndexes: [8], + }) + }) + + it('resets an incompatible route or disappeared input to automatic', () => { + const core: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0']} + expect(normalizeCoinControlSelection(core, TransferOperation.AddressFundsTransfer, inventory)).toEqual(automaticCoinControl()) + expect(normalizeCoinControlSelection( + {kind: 'shieldedNotes', noteIndexes: [99]}, + TransferOperation.ShieldedTransfer, + inventory, + )).toEqual(automaticCoinControl()) + }) + + it('rejects selections beyond route limits and invalid Platform input caps', () => { + const platformInputs = Array.from({length: PLATFORM_INPUT_LIMIT + 1}, (_, index) => ({ + address: `platform-${index}`, + credits: 1n, + })) + const platformInventory = { + ...inventory, + platformBalances: Object.fromEntries(platformInputs.map(input => [input.address, 1n])), + } + expect(normalizeCoinControlSelection({ + kind: 'platformInputs', + inputs: platformInputs, + feeAddress: platformInputs[0].address, + }, TransferOperation.AddressFundsTransfer, platformInventory)).toEqual(automaticCoinControl()) + + expect(normalizeCoinControlSelection({ + kind: 'platformInputs', + inputs: [{address: 'platform-a', credits: 5_000_001n}], + feeAddress: 'platform-a', + }, TransferOperation.AddressFundsTransfer, inventory)).toEqual(automaticCoinControl()) + + expect(normalizeCoinControlSelection({ + kind: 'shieldedNotes', + noteIndexes: Array.from({length: SHIELDED_NOTE_LIMIT + 1}, (_, index) => index), + }, TransferOperation.ShieldedTransfer, { + ...inventory, + shieldedNoteIndexes: Array.from({length: SHIELDED_NOTE_LIMIT + 1}, (_, index) => index), + })).toEqual(automaticCoinControl()) + }) +}) diff --git a/tests/unit/sendDraft.test.ts b/tests/unit/sendDraft.test.ts index 62b95de0..f8dd9d54 100644 --- a/tests/unit/sendDraft.test.ts +++ b/tests/unit/sendDraft.test.ts @@ -33,14 +33,6 @@ describe('send drafts', () => { toValue: 'recipient', amount: '1.25', acked: true, - specificSourcePreferences: { - enabled: true, - addresses: { - [SourceKind.Core]: 'core-source', - [SourceKind.PlatformAddress]: 'platform-source', - [SourceKind.Shielded]: 'shielded-source', - }, - }, } saveSendDraft('wallet-a', draft) diff --git a/tests/unit/specificSource.test.ts b/tests/unit/specificSource.test.ts deleted file mode 100644 index 980802d4..00000000 --- a/tests/unit/specificSource.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SourceKind } from '../../src/renderer/src/enums/SourceKind' -import { TransferOperation } from '../../src/renderer/src/enums/TransferOperation' -import { - initialSpecificSourcePreferences, - specificSourceKindForOperation, - updateSpecificSourceAddress, - updateSpecificSourceEnabled, -} from '../../src/renderer/src/utils/specificSource' - -describe('specific source preferences', () => { - it('stays enabled when switching from a Core method to a Shielded method', () => { - const preferences = updateSpecificSourceEnabled(initialSpecificSourcePreferences(), true) - - expect(preferences.enabled).toBe(true) - expect(specificSourceKindForOperation(TransferOperation.CoreSend)).toBe(SourceKind.Core) - expect(specificSourceKindForOperation(TransferOperation.ShieldedTransfer)).toBe(SourceKind.Shielded) - expect(preferences.enabled).toBe(true) - }) - - it('keeps every source kind\'s address independent when the shared setting is toggled', () => { - const withCore = updateSpecificSourceAddress(initialSpecificSourcePreferences(), SourceKind.Core, 'core-address') - const withPlatform = updateSpecificSourceAddress(withCore, SourceKind.PlatformAddress, 'platform-address') - const withShielded = updateSpecificSourceAddress(withPlatform, SourceKind.Shielded, 'shielded-address') - const enabled = updateSpecificSourceEnabled(withShielded, true) - const disabled = updateSpecificSourceEnabled(enabled, false) - const enabledAgain = updateSpecificSourceEnabled(disabled, true) - - expect(enabledAgain).toEqual({ - enabled: true, - addresses: { - [SourceKind.Core]: 'core-address', - [SourceKind.PlatformAddress]: 'platform-address', - [SourceKind.Shielded]: 'shielded-address', - }, - }) - }) - - it.each([ - [TransferOperation.CoreSend, SourceKind.Core], - [TransferOperation.ShieldedTransfer, SourceKind.Shielded], - [TransferOperation.Unshield, SourceKind.Shielded], - [TransferOperation.ShieldedWithdrawal, SourceKind.Shielded], - [TransferOperation.IdentityCreateFromShielded, null], - // Funded by L1 coins like a plain send, so the same picker applies. - [TransferOperation.AssetLockFunding, SourceKind.Core], - [TransferOperation.AssetLockShield, SourceKind.Core], - [TransferOperation.IdentityRegister, SourceKind.Core], - [TransferOperation.IdentityTopUpL1, SourceKind.Core], - // Funded by platform addresses, whose inputs are what a pick names here. - [TransferOperation.AddressFundsTransfer, SourceKind.PlatformAddress], - [TransferOperation.IdentityCreate, SourceKind.PlatformAddress], - [TransferOperation.IdentityTopUp, SourceKind.PlatformAddress], - [TransferOperation.AddressWithdrawal, SourceKind.PlatformAddress], - // Spends its source address whole, so it has no set to pick from. - [TransferOperation.Shield, null], - ])('maps %s to its applicable preference', (operation, expected) => { - expect(specificSourceKindForOperation(operation)).toBe(expected) - }) -}) From 939b6bb5fe9ba13646bef54a50f71e90cedcfe0f Mon Sep 17 00:00:00 2001 From: 0x1337 Date: Sat, 5 Sep 2026 00:11:15 +0700 Subject: [PATCH 17/31] fix: refine coin control selection UI --- .../pages/transfer/CoinControlModal.tsx | 261 +++++++++++++++--- .../pages/transfer/CoreRecipientsTest.tsx | 65 ----- .../pages/transfer/PlatformRecipientsTest.tsx | 65 ----- .../pages/transfer/ShieldedRecipientsTest.tsx | 65 ----- .../components/pages/transfer/TransferHub.tsx | 164 +++++------ src/renderer/src/constants/core.ts | 4 +- src/renderer/src/constants/platform.ts | 4 +- src/renderer/src/constants/shielded.ts | 5 +- 8 files changed, 295 insertions(+), 338 deletions(-) delete mode 100644 src/renderer/src/components/pages/transfer/CoreRecipientsTest.tsx delete mode 100644 src/renderer/src/components/pages/transfer/PlatformRecipientsTest.tsx delete mode 100644 src/renderer/src/components/pages/transfer/ShieldedRecipientsTest.tsx diff --git a/src/renderer/src/components/pages/transfer/CoinControlModal.tsx b/src/renderer/src/components/pages/transfer/CoinControlModal.tsx index 1ee32214..4b1dda3f 100644 --- a/src/renderer/src/components/pages/transfer/CoinControlModal.tsx +++ b/src/renderer/src/components/pages/transfer/CoinControlModal.tsx @@ -6,14 +6,16 @@ import { DashLogo } from 'dash-ui-kit/react' import Checkbox from '@renderer/components/ui/Checkbox' import CreditsAmount from '@renderer/components/ui/CreditsAmount' import type { PlatformAddressDto, SelectableUtxo, ShieldedNoteInfo, WalletAddressDto } from '@renderer/api/types' -import { PLATFORM_INPUT_LIMIT } from '@renderer/constants/platform' -import { SHIELDED_NOTE_LIMIT } from '@renderer/constants/shielded' +import { CORE_DUST_FILTER_DUFFS } from '@renderer/constants/core' +import { PLATFORM_DUST_FILTER_CREDITS, PLATFORM_INPUT_LIMIT } from '@renderer/constants/platform' +import { SHIELDED_DUST_FILTER_CREDITS, SHIELDED_NOTE_LIMIT } from '@renderer/constants/shielded' import { SourceKind } from '@renderer/enums/SourceKind' import { TransferOperation } from '@renderer/enums/TransferOperation' import { CoinControlMode } from '@renderer/enums/CoinControlMode' import type { CoinControlSelection } from '@renderer/types/CoinControl' import { automaticCoinControl, coinControlSourceKind, outpointKey } from '@renderer/utils/coinControl' -import { davToDashCompact } from '@renderer/utils/balance' +import { creditsToDuffs, davToDashCompact } from '@renderer/utils/balance' +import { shieldedBalancesByAddress } from '@renderer/utils/shieldedBalances' const FIXED_SOURCE_COPY: Partial> = { [TransferOperation.Shield]: { @@ -27,7 +29,7 @@ const FIXED_SOURCE_COPY: Partial = { - [SourceKind.Core]: 'Coins', + [SourceKind.Core]: 'UTXOs', [SourceKind.PlatformAddress]: 'Inputs', [SourceKind.Identity]: 'Inputs', [SourceKind.Shielded]: 'Notes', @@ -39,11 +41,15 @@ interface CoinControlModalProps { selection: CoinControlSelection coreAddresses: WalletAddressDto[] utxos: SelectableUtxo[] + utxosLoading: boolean + utxosError: string | null + coreSyncIncomplete: boolean platformAddresses: PlatformAddressDto[] shieldedNotes: ShieldedNoteInfo[] identityLabel: string | null identityId: string | null platformAddress: PlatformAddressDto | undefined + onRetryUtxos: () => void onClose: () => void onApply: (selection: CoinControlSelection) => void } @@ -54,26 +60,89 @@ export default function CoinControlModal({ selection, coreAddresses, utxos, + utxosLoading, + utxosError, + coreSyncIncomplete, platformAddresses, shieldedNotes, identityLabel, identityId, platformAddress, + onRetryUtxos, onClose, onApply, }: CoinControlModalProps): React.JSX.Element | null { const {theme} = useTheme() const [draft, setDraft] = useState(selection) + const [filterDust, setFilterDust] = useState(false) + const [onlySelected, setOnlySelected] = useState(false) useEffect(() => { - if (isOpen) setDraft(selection) + if (!isOpen) return + setDraft(selection) + setOnlySelected(false) }, [isOpen, selection]) if (!isOpen || operation == null) return null const sourceKind = coinControlSourceKind(operation) - const shieldedAddresses = [...new Set(shieldedNotes.map(note => note.address))] + const shieldedBalances = shieldedBalancesByAddress(shieldedNotes) + const shieldedAddresses = [...shieldedBalances.keys()] + const nonDustShieldedAddresses = [...shieldedBalances.entries()] + .filter(([, credits]) => credits >= SHIELDED_DUST_FILTER_CREDITS) + .map(([address]) => address) + const visibleShieldedAddresses = filterDust ? nonDustShieldedAddresses : shieldedAddresses + const nonDustShieldedNotes = shieldedNotes.filter(note => note.amount >= SHIELDED_DUST_FILTER_CREDITS) + const visibleShieldedNotes = filterDust ? nonDustShieldedNotes : shieldedNotes + const nonDustCoreAddresses = coreAddresses.filter(address => address.balance >= CORE_DUST_FILTER_DUFFS) + const visibleCoreAddresses = filterDust ? nonDustCoreAddresses : coreAddresses + const nonDustUtxos = utxos.filter(utxo => utxo.satoshis >= CORE_DUST_FILTER_DUFFS) + const visibleUtxos = filterDust ? nonDustUtxos : utxos + const selectedOutpoints = draft.kind === 'coreOutpoints' ? new Set(draft.outpoints) : new Set() + const selectedUtxos = visibleUtxos.filter(utxo => selectedOutpoints.has(outpointKey(utxo))) + const selectedDuffs = selectedUtxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n) + const displayedUtxos = onlySelected && selectedUtxos.length > 0 ? selectedUtxos : visibleUtxos + + const nonDustPlatformAddresses = platformAddresses.filter(address => address.balanceCredits >= PLATFORM_DUST_FILTER_CREDITS) + const visiblePlatformAddresses = filterDust ? nonDustPlatformAddresses : platformAddresses const selectedPlatformInputs = draft.kind === 'platformInputs' ? draft.inputs : [] + const selectedPlatformAddresses = new Set(selectedPlatformInputs.map(input => input.address)) + const displayedPlatformAddresses = onlySelected && selectedPlatformInputs.length > 0 + ? visiblePlatformAddresses.filter(address => selectedPlatformAddresses.has(address.platformAddress)) + : visiblePlatformAddresses + const selectedPlatformCredits = selectedPlatformInputs.reduce((sum, input) => sum + input.credits, 0n) + + const selectedNoteIndexes = draft.kind === 'shieldedNotes' ? new Set(draft.noteIndexes) : new Set() + const selectedShieldedNotes = visibleShieldedNotes.filter(note => selectedNoteIndexes.has(note.index)) + const displayedShieldedNotes = onlySelected && selectedShieldedNotes.length > 0 + ? selectedShieldedNotes + : visibleShieldedNotes + const selectedShieldedCredits = selectedShieldedNotes.reduce((sum, note) => sum + note.amount, 0n) + + let selectedCount = 0 + let selectedAmountDuffs = 0n + let selectedItemSingular = 'input' + let selectedItemPlural = 'inputs' + switch (sourceKind) { + case SourceKind.Core: + selectedCount = selectedUtxos.length + selectedAmountDuffs = selectedDuffs + selectedItemSingular = 'UTXO' + selectedItemPlural = 'UTXOs' + break + case SourceKind.PlatformAddress: + selectedCount = selectedPlatformInputs.length + selectedAmountDuffs = creditsToDuffs(selectedPlatformCredits) + break + case SourceKind.Shielded: + selectedCount = selectedShieldedNotes.length + selectedAmountDuffs = creditsToDuffs(selectedShieldedCredits) + selectedItemSingular = 'note' + selectedItemPlural = 'notes' + break + } + const selectedItemLabel = selectedCount === 1 ? selectedItemSingular : selectedItemPlural + const platformInputsValid = draft.kind !== 'platformInputs' || ( draft.inputs.length > 0 && draft.inputs.length <= PLATFORM_INPUT_LIMIT @@ -112,6 +181,7 @@ export default function CoinControlModal({ } const chooseMode = (nextMode: CoinControlMode): void => { + setOnlySelected(false) if (nextMode === CoinControlMode.Automatic) { setDraft(automaticCoinControl()) return @@ -120,21 +190,21 @@ export default function CoinControlModal({ switch (sourceKind) { case SourceKind.Core: if (nextMode === CoinControlMode.Address) { - setDraft({kind: 'coreAddress', address: coreAddresses[0]?.address ?? ''}) + setDraft({kind: 'coreAddress', address: visibleCoreAddresses[0]?.address ?? ''}) } else { setDraft({kind: 'coreOutpoints', outpoints: []}) } break case SourceKind.PlatformAddress: if (nextMode === CoinControlMode.Address) { - setDraft({kind: 'platformAddress', address: platformAddresses[0]?.platformAddress ?? ''}) + setDraft({kind: 'platformAddress', address: visiblePlatformAddresses[0]?.platformAddress ?? ''}) } else { setDraft({kind: 'platformInputs', inputs: [], feeAddress: ''}) } break case SourceKind.Shielded: if (nextMode === CoinControlMode.Address) { - setDraft({kind: 'shieldedAddress', address: shieldedAddresses[0] ?? ''}) + setDraft({kind: 'shieldedAddress', address: visibleShieldedAddresses[0] ?? ''}) } else { setDraft({kind: 'shieldedNotes', noteIndexes: []}) } @@ -181,15 +251,69 @@ export default function CoinControlModal({ let outpoints: string[] = [] if (draft.kind === 'coreOutpoints') outpoints = draft.outpoints if (checked) { + if (outpoints.length === 0) setOnlySelected(false) setDraft({kind: 'coreOutpoints', outpoints: [...outpoints, key]}) } else { - setDraft({kind: 'coreOutpoints', outpoints: outpoints.filter(value => value !== key)}) + const nextOutpoints = outpoints.filter(value => value !== key) + if (nextOutpoints.length === 0) setOnlySelected(false) + setDraft({kind: 'coreOutpoints', outpoints: nextOutpoints}) + } + } + + const toggleDustFilter = (checked: boolean): void => { + setFilterDust(checked) + if (!checked) return + + switch (draft.kind) { + case 'coreAddress': + if (!nonDustCoreAddresses.some(address => address.address === draft.address)) { + setDraft({kind: 'coreAddress', address: nonDustCoreAddresses[0]?.address ?? ''}) + } + break + case 'coreOutpoints': { + const visibleOutpoints = new Set(nonDustUtxos.map(outpointKey)) + const outpoints = draft.outpoints.filter(outpoint => visibleOutpoints.has(outpoint)) + if (outpoints.length === 0) setOnlySelected(false) + setDraft({ + kind: 'coreOutpoints', + outpoints, + }) + break + } + case 'platformAddress': + if (!nonDustPlatformAddresses.some(address => address.platformAddress === draft.address)) { + setDraft({kind: 'platformAddress', address: nonDustPlatformAddresses[0]?.platformAddress ?? ''}) + } + break + case 'platformInputs': { + const visibleAddresses = new Set(nonDustPlatformAddresses.map(address => address.platformAddress)) + const inputs = draft.inputs.filter(input => visibleAddresses.has(input.address)) + let feeAddress = draft.feeAddress + if (!inputs.some(input => input.address === feeAddress)) feeAddress = inputs[0]?.address ?? '' + if (inputs.length === 0) setOnlySelected(false) + setDraft({kind: 'platformInputs', inputs, feeAddress}) + break + } + case 'shieldedAddress': + if (!nonDustShieldedAddresses.includes(draft.address)) { + setDraft({kind: 'shieldedAddress', address: nonDustShieldedAddresses[0] ?? ''}) + } + break + case 'shieldedNotes': { + const visibleNoteIndexes = new Set(nonDustShieldedNotes.map(note => note.index)) + setDraft({ + kind: 'shieldedNotes', + noteIndexes: draft.noteIndexes.filter(index => visibleNoteIndexes.has(index)), + }) + break + } } } const togglePlatformInput = (entry: PlatformAddressDto, checked: boolean): void => { if (checked && selectedPlatformInputs.length >= PLATFORM_INPUT_LIMIT) return if (checked) { + if (selectedPlatformInputs.length === 0) setOnlySelected(false) const inputs = [...selectedPlatformInputs, {address: entry.platformAddress, credits: entry.balanceCredits}] const feeAddress = draft.kind === 'platformInputs' && draft.feeAddress ? draft.feeAddress @@ -199,6 +323,7 @@ export default function CoinControlModal({ } const inputs = selectedPlatformInputs.filter(input => input.address !== entry.platformAddress) + if (inputs.length === 0) setOnlySelected(false) let feeAddress = inputs[0]?.address ?? '' if (draft.kind === 'platformInputs' && draft.feeAddress !== entry.platformAddress) { feeAddress = draft.feeAddress @@ -222,9 +347,12 @@ export default function CoinControlModal({ if (draft.kind === 'shieldedNotes') noteIndexes = draft.noteIndexes if (checked) { if (noteIndexes.length >= SHIELDED_NOTE_LIMIT) return + if (noteIndexes.length === 0) setOnlySelected(false) setDraft({kind: 'shieldedNotes', noteIndexes: [...noteIndexes, index]}) } else { - setDraft({kind: 'shieldedNotes', noteIndexes: noteIndexes.filter(noteIndex => noteIndex !== index)}) + const nextNoteIndexes = noteIndexes.filter(noteIndex => noteIndex !== index) + if (nextNoteIndexes.length === 0) setOnlySelected(false) + setDraft({kind: 'shieldedNotes', noteIndexes: nextNoteIndexes}) } } @@ -239,7 +367,7 @@ export default function CoinControlModal({ return createPortal(
-
+
Coin control
@@ -252,7 +380,7 @@ export default function CoinControlModal({
-
+
{fixed ? (
{fixedCopy.title} @@ -269,19 +397,49 @@ export default function CoinControlModal({ {modeButton(CoinControlMode.Inputs, inputModeLabel)}
+ {sourceKind != null && mode !== CoinControlMode.Automatic && ( +
+ {mode === CoinControlMode.Inputs && ( +
+ + Selected: {selectedCount} {selectedItemLabel} · {davToDashCompact(selectedAmountDuffs)} Dash + + {selectedCount > 0 && ( + Only selected} + /> + )} +
+ )} + Filter dust} + className={'ml-auto'} + /> +
+ )} + {mode === CoinControlMode.Automatic && (
- Let the wallet choose - - The wallet will select enough available inputs for the amount and fee. - +
Let the wallet choose
+
+ + The wallet will select enough available inputs for the amount and fee. + +
)} {mode === CoinControlMode.Address && sourceKind === SourceKind.Core && (
{coreAddresses.length === 0 && } - {coreAddresses.map(entry => ( + {coreAddresses.length > 0 && visibleCoreAddresses.length === 0 && ( + + )} + {visibleCoreAddresses.map(entry => ( setDraft({kind: 'coreAddress', address: entry.address})}> @@ -293,7 +451,10 @@ export default function CoinControlModal({ {mode === CoinControlMode.Address && sourceKind === SourceKind.PlatformAddress && (
{platformAddresses.length === 0 && } - {platformAddresses.map(entry => ( + {platformAddresses.length > 0 && visiblePlatformAddresses.length === 0 && ( + + )} + {visiblePlatformAddresses.map(entry => ( setDraft({kind: 'platformAddress', address: entry.platformAddress})}> } /> @@ -305,8 +466,11 @@ export default function CoinControlModal({ {mode === CoinControlMode.Address && sourceKind === SourceKind.Shielded && (
{shieldedAddresses.length === 0 && } - {shieldedAddresses.map(address => { - const total = shieldedNotes.filter(note => note.address === address).reduce((sum, note) => sum + note.amount, 0n) + {shieldedAddresses.length > 0 && visibleShieldedAddresses.length === 0 && ( + + )} + {visibleShieldedAddresses.map(address => { + const total = shieldedBalances.get(address) ?? 0n return ( setDraft({kind: 'shieldedAddress', address})}> @@ -319,8 +483,25 @@ export default function CoinControlModal({ {mode === CoinControlMode.Inputs && sourceKind === SourceKind.Core && (
- {utxos.length === 0 && } - {utxos.map(utxo => { + {coreSyncIncomplete && } + {!coreSyncIncomplete && utxosLoading && } + {!coreSyncIncomplete && !utxosLoading && utxosError != null && ( +
+ {utxosError} + +
+ )} + {!coreSyncIncomplete && !utxosLoading && utxosError == null && utxos.length === 0 && } + {!coreSyncIncomplete && !utxosLoading && utxosError == null && utxos.length > 0 && visibleUtxos.length === 0 && ( + + )} + {!coreSyncIncomplete && !utxosLoading && utxosError == null && displayedUtxos.map(utxo => { const key = outpointKey(utxo) const checked = draft.kind === 'coreOutpoints' && draft.outpoints.includes(key) return ( @@ -337,7 +518,10 @@ export default function CoinControlModal({
Up to {PLATFORM_INPUT_LIMIT} inputs. Set the maximum credits available from each. {platformAddresses.length === 0 && } - {platformAddresses.map(entry => { + {platformAddresses.length > 0 && visiblePlatformAddresses.length === 0 && ( + + )} + {displayedPlatformAddresses.map(entry => { const selected = selectedPlatformInputs.find(input => input.address === entry.platformAddress) const full = selectedPlatformInputs.length >= PLATFORM_INPUT_LIMIT const invalid = selected != null && (selected.credits <= 0n || selected.credits > entry.balanceCredits) @@ -348,18 +532,22 @@ export default function CoinControlModal({ } /> {selected && ( -
-