diff --git a/package.json b/package.json index 128fa2fe..049dcd6a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "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.6", - "dash-platform-sdk": "1.5.0-dev.9", + "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/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..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'}, @@ -79,6 +83,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/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/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/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..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)), } } @@ -56,8 +60,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/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 new file mode 100644 index 00000000..260d00c7 --- /dev/null +++ b/src/main/platform/operations/shielded/reads/checkNullifiers.ts @@ -0,0 +1,15 @@ +import {PlatformOperations} from '../../../types/messages' +import {OperationContext} from '../../types' +import {nullifierStatuses} from './nullifierStatuses' + +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 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..b47385ea 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,31 +23,42 @@ export async function buildTransition( anchor: Uint8Array, changeAddress: ShieldedAddress, ): Promise { - const {seed, recipient} = payload - const amount = payload.amountCredits - const base = { + const {seed, recipients} = payload + 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': + if (recipients.length > 1) { + 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(recipient), - transferAmount: amount, + recipient: OrchardAddressWASM.fromBech32m(recipients[0].address), + transferAmount: recipients[0].amountCredits, }) case 'unshield': return sdk.shielded.createStateTransition('unshield', { ...base, - outputAddress: recipient, - unshieldAmount: amount, + outputAddress: recipients[0].address, + unshieldAmount: recipients[0].amountCredits, }) case 'identityCreateFromShielded': { @@ -53,7 +70,7 @@ export async function buildTransition( ...base, publicKeys: keys.publicKeys, privateKeys: keys.privateKeys, - denomination: amount, + denomination: payload.amountCredits, sendToAddressOnCreationFailure: payload.failureAddress, }) } @@ -61,8 +78,8 @@ export async function buildTransition( case 'shieldedWithdrawal': return sdk.shielded.createStateTransition('shieldedWithdrawal', { ...base, - withdrawalAmount: amount, - outputScript: coreAddressToScript(recipient, network), + withdrawalAmount: recipients[0].amountCredits, + 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 b74bcbe5..e4227da6 100644 --- a/src/main/platform/operations/shielded/spend/spend.ts +++ b/src/main/platform/operations/shielded/spend/spend.ts @@ -1,12 +1,18 @@ -import {IdentityCreateFromShieldedPoolTransitionWASM} from 'pshenmic-dpp' -import {maxSpendableCredits, selectSpendNotes} from '../../../../src/utils/shieldedNoteSelection' +import {IdentityCreateFromShieldedPoolTransitionWASM, RecoveredNoteWASM} from 'pshenmic-dpp' +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' 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,30 +21,55 @@ 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') + // 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 + // a pool index, which is what the note tables and the user's pick key on. + const poolIndex = (note: RecoveredNoteWASM): number => 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 + // 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, + ) + + // 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 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 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 +77,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 +106,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 15d90502..41107959 100644 --- a/src/main/platform/types/messages.ts +++ b/src/main/platform/types/messages.ts @@ -1,5 +1,8 @@ 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 // shapes live with their operations. Every terminal event echoes back the @@ -48,6 +51,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 { @@ -99,7 +105,6 @@ export type PoolSpendOperation = | 'identityCreateFromShielded' export type TransitionFeeOperation = - | 'addressFundsTransfer' | 'shield' | 'identityToAddress' | 'identityToIdentity' @@ -118,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' @@ -131,12 +137,19 @@ 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 + // L1 quotes only: narrows the funding to one Core address, or to coins the + // 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: 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 @@ -186,11 +199,14 @@ 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. 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[] - // 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 @@ -227,8 +243,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[]} @@ -240,19 +256,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: { @@ -313,6 +334,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 dbeb93d5..49f3463b 100644 --- a/src/main/src/WalletBackend.ts +++ b/src/main/src/WalletBackend.ts @@ -85,6 +85,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' @@ -102,7 +103,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' @@ -206,7 +207,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) @@ -218,6 +219,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) @@ -272,7 +274,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/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..8e71489f 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 {ShieldedRecipient, 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, recipients: ShieldedRecipient[], password: string, source?: ShieldedSpendSource): Promise => { + return this.shieldedService.startTransfer(walletId, password, recipients, 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/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/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/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/sendTransaction.ts b/src/main/src/api/wallet/sendTransaction.ts index 7ffd9002..6be723e1 100644 --- a/src/main/src/api/wallet/sendTransaction.ts +++ b/src/main/src/api/wallet/sendTransaction.ts @@ -1,5 +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 { @@ -12,11 +14,10 @@ export class SendTransactionHandler { handle = async ( _event: IpcMainInvokeEvent, walletId: string, - toAddress: string, - amountDuffs: bigint, + recipients: CoreRecipient[], password: string, - fromAddress?: string, + source?: CoreSpendSource, ): Promise => { - return this.walletService.sendTransaction(walletId, toAddress, amountDuffs, password, fromAddress) + return this.walletService.sendTransaction(walletId, recipients, password, source) } } 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/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/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/constants/chain.ts b/src/main/src/constants/chain.ts index c955d2aa..fa9f25c6 100644 --- a/src/main/src/constants/chain.ts +++ b/src/main/src/constants/chain.ts @@ -1,9 +1,16 @@ 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 + +// 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. export const COINBASE_PREV_TXID = '0'.repeat(64) diff --git a/src/main/src/constants/credits.ts b/src/main/src/constants/credits.ts index c9e650ed..447ad22e 100644 --- a/src/main/src/constants/credits.ts +++ b/src/main/src/constants/credits.ts @@ -11,8 +11,23 @@ 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 +// 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 export const ASSET_LOCK_CREDIT_OUTPUT_INDEX = 0 diff --git a/src/main/src/database/ShieldedNoteDAO.ts b/src/main/src/database/ShieldedNoteDAO.ts index 59c3a6b6..41165201 100644 --- a/src/main/src/database/ShieldedNoteDAO.ts +++ b/src/main/src/database/ShieldedNoteDAO.ts @@ -21,7 +21,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) => ({ @@ -29,6 +29,7 @@ export class ShieldedNoteDAO { amount: BigInt(row.amount), address: row.address, spent: Boolean(row.spent), + nullifier: row.nullifier ?? null, })) } @@ -41,9 +42,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']) } } 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/CoreLockService.ts b/src/main/src/services/core/CoreLockService.ts index 6be58ecf..5e67ed18 100644 --- a/src/main/src/services/core/CoreLockService.ts +++ b/src/main/src/services/core/CoreLockService.ts @@ -6,7 +6,9 @@ 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 {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' import {CoreTransactionService} from './CoreTransactionService' @@ -41,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') @@ -51,12 +54,14 @@ 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, ASSET_LOCK_PAYLOAD_BYTES), + source, ) const creditTarget = credit ?? pickCreditChangeAddress(grouped, changeAddress) @@ -67,6 +72,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..86b0b210 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,26 +72,40 @@ 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, 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) - transaction.generateChange(changeAddress, inputTotal) + + this.addChange(transaction, inputTotal - outputTotal - feeDuffs, changeAddress) transaction.sign(privateKeys) return transaction diff --git a/src/main/src/services/core/WalletSyncService.ts b/src/main/src/services/core/WalletSyncService.ts index 14413117..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' @@ -815,13 +815,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/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..6903d5e4 100644 --- a/src/main/src/services/platform/PlatformTransferService.ts +++ b/src/main/src/services/platform/PlatformTransferService.ts @@ -5,6 +5,8 @@ import {PlatformWorkerService} from './PlatformWorkerService' import {ShieldedService} from './ShieldedService' import {IdentityDAO} from '../../database/IdentityDAO' import {AssetLockFundingState} from '../../types/AssetLockFunding' +import {CoreSpendSource} from '../../types/CoinSelection' +import {PlatformSpendSource} from '../../types/PlatformTransfer' import {Network} from '../../types/Network' import {Wallet} from '../../types/Wallet' import {Identity} from '../../types/Identity' @@ -13,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} from '../../../platform/types/messages' // Every way credits move on L2: between platform addresses, to and from @@ -62,39 +65,39 @@ 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) 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, } } @@ -104,22 +107,13 @@ 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, + amountCredits: totalCredits, recipient: recipients.map(entry => entry.address), identityId: identityIdentifier, }) @@ -183,12 +177,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) @@ -198,7 +192,7 @@ 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) @@ -206,6 +200,7 @@ export class PlatformTransferService { seed, identityIndex, inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, }) await this.identityDAO.insertIdentities([{ @@ -228,7 +223,7 @@ export class PlatformTransferService { async topUpIdentityFromAddresses( walletId: string, identityId: string, - fromPlatformAddress: string | null, + source: PlatformSpendSource | null, amountCredits: bigint, password: string, ): Promise { @@ -243,7 +238,7 @@ 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) @@ -251,6 +246,7 @@ export class PlatformTransferService { seed, identifier: identityId, inputs: plan.inputs.map(({candidate, credits}) => toAddressInput(candidate, credits)), + feeStrategy: plan.feeStrategy, }) return { @@ -264,7 +260,7 @@ export class PlatformTransferService { async withdrawPlatformToCore( walletId: string, - fromPlatformAddress: string | null, + source: PlatformSpendSource | null, toCoreAddress: string, amountCredits: bigint, password: string, @@ -277,13 +273,14 @@ 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) 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), }) @@ -354,7 +351,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) @@ -382,7 +379,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 +393,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 eafcddb8..9a4f22fb 100644 --- a/src/main/src/services/platform/ShieldedService.ts +++ b/src/main/src/services/platform/ShieldedService.ts @@ -1,8 +1,10 @@ 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' 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' @@ -11,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' @@ -23,7 +25,6 @@ import {Preferences} from '../../preferences' import { lockedDuffsFor, shieldAmountFromLockedDuffs } from '../../utils/assetLockTx' import { PlatformWorkerService } from './PlatformWorkerService' import { - ShieldedNoteInfo, ShieldedPoolInfo, ShieldedNotesInfo, ShieldedSpendPhase, @@ -33,7 +34,14 @@ import { ShieldedSyncState, } from '../../types/Shielded' import {OperationFee} from '../../types/Fee' -import {maxSpendableCredits, 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, @@ -45,6 +53,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 } @@ -349,13 +359,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() } @@ -387,11 +399,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) @@ -422,16 +435,18 @@ 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) + // 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, 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', [{address: 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', [{address: coreAddress, amountCredits}], source) } // Returns the in-flight state when a spend is already running for this @@ -446,13 +461,13 @@ 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, 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 @@ -464,10 +479,10 @@ export class ShieldedService { this.runSpend(walletId, network, state, { seed, kind, - recipient, + recipients, amountCredits, notes, - noteIndexes: noteIndexes ?? null, + source: source ?? null, identityIndex: null, failureAddress: null, coreFeePerByte: coreFeePerByte(this.preferences.general.coreFeeMultiplier), @@ -533,10 +548,10 @@ export class ShieldedService { this.runSpend(walletId, network, state, { seed, kind: 'identityCreateFromShielded', - recipient: '', + recipients: [], amountCredits: denominationCredits, notes, - noteIndexes: null, + source: null, identityIndex, failureAddress, coreFeePerByte: coreFeePerByte(this.preferences.general.coreFeeMultiplier), @@ -550,7 +565,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 +585,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) }) @@ -634,25 +649,32 @@ export class ShieldedService { walletId: string, kind: PoolSpendOperation, amountCredits: bigint, - noteIndexes: number[] | null, + 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] - - 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})) + // 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 ?? []) + .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, MAX_SPEND_NOTES, feeForCount, source) : null return { feeCredits: selection?.feeCredits ?? feeForCount(1), feeDuffs: null, - maxPerTx: maxSpendableCredits(candidates, curve.length, feeForCount), - noteLimit: curve.length, + maxDuffs: null, + maxPerTx: maxSpendableCredits(candidates, MAX_SPEND_NOTES, feeForCount, source), + noteLimit: MAX_SPEND_NOTES, } } @@ -667,6 +689,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 80034672..92b44fe5 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' @@ -12,9 +14,18 @@ import { SelectionFeeOperation, TransitionFeeOperation, } from '../../../platform/types/messages' +import {ASSET_LOCK_PAYLOAD_BYTES} from '../../constants/chain' import {requireWallet} from '../../utils/requireWallet' -import {selectPlatformInputsWithFee} from '../../utils/platformTransfer' -import {coreFeeDuffs, coreFeePerByte} from '../../utils/coreFeeRate' +import {maxSelectableAmount, requireAutomaticSelection, selectCoins} from '../../utils/coinSelection' +import { + PlatformFeeForInputs, + maxPlatformCredits, + requireAutomaticInputs, + selectPlatformInputsWithFee, + selectablePlatformInputs, +} from '../../utils/platformTransfer' +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 +42,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 +71,15 @@ 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. - case 'coreSend': - return {feeCredits: null, feeDuffs: coreFeeDuffs(coreFeeMultiplier), maxPerTx: null, noteLimit: null} + // 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) + 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 @@ -68,9 +88,11 @@ export class FeeService { case 'assetLockShield': case 'identityRegister': case 'identityTopUpL1': + requireAutomaticInputs(params.platformSource) return { feeCredits: await this.protocolFee(wallet, operation, params, 1), - feeDuffs: coreFeeDuffs(coreFeeMultiplier), + // 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, } @@ -79,28 +101,38 @@ export class FeeService { case 'shieldedTransfer': case 'unshield': case 'shieldedWithdrawal': - case 'identityCreateFromShielded': - return this.shielded.estimateSpendFee(walletId, operation, params.amountCredits, params.noteIndexes ?? null) + case 'identityCreateFromShielded': { + requireAutomaticSelection(params.coreSource) + requireAutomaticInputs(params.platformSource) + 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. + case 'addressFundsTransfer': case 'addressWithdrawal': case 'identityCreate': case 'identityTopUp': - return this.credits(await this.selectionFee(wallet, operation, params)) + requireAutomaticSelection(params.coreSource) + 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. case 'identityToAddress': 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)) } } @@ -122,10 +154,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), ) } @@ -148,17 +181,74 @@ 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 + // 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, outputsCount: number, payloadBytes: number): Promise<{feeDuffs: bigint; maxDuffs: bigint}> { + const feeForInputs = (inputsCount: number): bigint => + coreFeeDuffsFor(this.preferences.general.coreFeeMultiplier, inputsCount, outputsCount, 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, source) + + 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 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, source).fee + : feeForInputs(floorInputs) + + 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..9a9b904e 100644 --- a/src/main/src/services/wallet/WalletService.ts +++ b/src/main/src/services/wallet/WalletService.ts @@ -26,10 +26,12 @@ 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' +import {CoreSpendSource, SelectableUtxo} from '../../types/CoinSelection' +import {CoreRecipient} from '../../types/CoreTransaction' +import {requireCoreRecipients, selectableTransferUtxos, selectTransferInputs} from '../../utils/transferInputs' import {Preferences} from '../../preferences' import {ConnectionStatus} from '../../types/ConnectionStatus' @@ -260,6 +262,19 @@ 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()) + } + async getTransactionByHash(hash: string, network: Network): Promise { if (network !== 'mainnet' && network !== 'testnet') { throw new Error('Invalid network ("mainnet", "testnet")') @@ -303,39 +318,41 @@ 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, - fromAddress?: 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() - 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), - fromAddress, + inputsCount => coreFeeDuffsFor(coreFeeMultiplier, inputsCount, outputs.length, true), + source, ) const tx = await this.coreTransactionService.buildSignedTransfer({ inputs: transferInputs, - toAddress, - recipientType, - amount: amountDuffs, + outputs, changeAddress, inputTotal, + feeDuffs, seed, network, }) @@ -346,13 +363,12 @@ 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, changeAddress: hasChange ? changeAddress : null, peersAcked: broadcast.peersDelivered.length, } 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/types/CoinSelection.ts b/src/main/src/types/CoinSelection.ts index e4ebfb60..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 @@ -12,6 +28,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..58cf4b7b 100644 --- a/src/main/src/types/CoreTransaction.ts +++ b/src/main/src/types/CoreTransaction.ts @@ -11,19 +11,32 @@ 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 changeAddress: string + feeDuffs: bigint } export interface BuildSignedTransferParams { inputs: TransferInput[] - toAddress: string - recipientType: RecipientType - amount: bigint + outputs: TransferOutput[] 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/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/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/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..7f020bd7 100644 --- a/src/main/src/types/ShieldedNoteSelection.ts +++ b/src/main/src/types/ShieldedNoteSelection.ts @@ -3,10 +3,31 @@ 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[]} + +// 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 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/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 328927ef..f1b97740 100644 --- a/src/main/src/utils/coinSelection.ts +++ b/src/main/src/utils/coinSelection.ts @@ -1,15 +1,49 @@ -import {CoinSelectionParams, CoinSelectionResult, 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) + +// 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, - params: CoinSelectionParams, + feeForInputs: CoreFeeForInputs, + source?: CoreSpendSource, ): 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)) + // 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[] = [] let inputTotal = 0n @@ -18,11 +52,41 @@ 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, + 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 + 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..3c7fcaae 100644 --- a/src/main/src/utils/coreFeeRate.ts +++ b/src/main/src/utils/coreFeeRate.ts @@ -1,4 +1,6 @@ -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' +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. @@ -16,8 +18,30 @@ 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, payloadBytes = 0): bigint { + const feePerByte = coreFeePerByte(multiplier) + + // dummy script which bigger than 99% of sigs + const dummyScript = new Script(`OP_PUSHDATA1 ${'0'.repeat(288)}`) + + + const dummyInput = new Input('0'.repeat(64), 1, dummyScript, 0) + const dummyOutput = new Output(1n, dummyScript) + + const tx = new Transaction( + [dummyInput], + [dummyOutput], + 0, + 0, + ) + + if(withChange) { + tx.outputs.push(Output.createP2PKH(1n, '111111111111111111111111133izVn')) + } + + // 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) } 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/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..816f7d44 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,54 @@ 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 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 picked = new Set(source.inputs.map(input => input.address)) + return candidates.filter(candidate => picked.has(candidate.platformAddress)) + } + + 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 +96,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 +116,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 +179,96 @@ 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') + } + 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}) + } + + 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 feeTargetMax = feeTarget.balanceCredits - feeCredits +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 +276,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/main/src/utils/shieldedNoteSelection.ts b/src/main/src/utils/shieldedNoteSelection.ts index 13ac6098..e8051da3 100644 --- a/src/main/src/utils/shieldedNoteSelection.ts +++ b/src/main/src/utils/shieldedNoteSelection.ts @@ -1,16 +1,82 @@ -import {NoteSelectionResult, SelectableNote, SpendFeeForCount} from '../types/ShieldedNoteSelection' +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 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) + return notes + .filter(note => !note.spent) + .filter(note => restricted == null || restricted.has(note.index)) + .map(({index, value}) => ({index, value})) +} + +// 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( 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. + // 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 + } + const sorted = [...notes].sort(byValueDesc) const selected: SelectableNote[] = [] let total = 0n @@ -28,7 +94,17 @@ 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 + if (picksMissingNotes(notes, source)) 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/main/src/utils/transferInputs.ts b/src/main/src/utils/transferInputs.ts index 54c2f4bb..e0c5fa80 100644 --- a/src/main/src/utils/transferInputs.ts +++ b/src/main/src/utils/transferInputs.ts @@ -1,9 +1,31 @@ import {GroupedAddresses} from '../types/GroupedAddresses' -import {SelectableUtxo} from '../types/CoinSelection' -import {TransferInput, TransferInputSelection} from '../types/CoreTransaction' +import {CoreFeeForInputs, CoreSpendSource, SelectableUtxo} from '../types/CoinSelection' +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}` + +const pickedOutpointKeys = (source?: CoreSpendSource): Set | null => + source?.kind === 'outpoints' + ? 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 { @@ -29,39 +51,58 @@ 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[], + 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 => 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, + })) +} + export function selectTransferInputs( grouped: GroupedAddresses, utxos: UTXO[], amountDuffs: bigint, - feeDuffs: bigint, - fromAddress?: string, + feeForInputs: CoreFeeForInputs, + source?: CoreSpendSource, ): 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, source) - 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, - })) + // 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, {fee: feeDuffs}) - const utxoByKey = new Map(ownedUtxos.map(u => [`${u.txId}:${u.vOut}`, u])) + 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) @@ -76,5 +117,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/definitions.ts b/src/preload/definitions.ts index 6fcfcc3b..613e786a 100644 --- a/src/preload/definitions.ts +++ b/src/preload/definitions.ts @@ -2,6 +2,39 @@ // 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 }[] } + +// 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 = + | { 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 } +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), @@ -26,17 +59,17 @@ 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, 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, 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), - startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: string) => ipcRenderer.invoke('startAssetLockFunding', walletId, toPlatformAddress, amountDuffs, password, kind), + 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), dismissAssetLockFunding: (walletId: string) => ipcRenderer.invoke('dismissAssetLockFunding', walletId), @@ -64,7 +97,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), @@ -84,9 +117,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, 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), 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 efa02c6c..49ed8811 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -4,6 +4,49 @@ 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 }[] } + +// 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 = + | { 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 } +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 { + 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,17 +117,17 @@ 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, 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; 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 + 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 + 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 - startAssetLockFunding: (walletId: string, toPlatformAddress: string, amountDuffs: bigint, password: string, kind?: 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 dismissAssetLockFunding: (walletId: string) => Promise @@ -111,7 +154,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 @@ -126,9 +169,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, 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 }> 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 5f32001b..257ba91c 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, PeerInfo, PeerMode, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus } from './types' +import { AssetLockFundingKind, AssetLockFundingState, ConnectionType, Contact, ExchangeRatesResult, IdentityCreateResult, LogFileContent, LogFileInfo, Network, PeerInfo, PeerMode, PlatformAddressDto, PlatformSendResult, PreferencesJSON, SendResult, ShieldResult, ShieldedNotesInfo, ShieldedPoolInfo, ShieldedSpendState, ShieldedStatus, ShieldedSyncState, FeeParams, OperationFee, Transaction, TxLockStatus, CoreRecipient, CoreSpendSource, PlatformSpendSource, PlatformRecipient, ShieldedRecipient, ShieldedSpendSource, SelectableUtxo} from './types' export class API { private static get api() { @@ -119,6 +119,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 } @@ -203,8 +207,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, 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 { @@ -231,16 +235,20 @@ export class API { return this.api.getShieldedSyncState(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 refreshShieldedSpentNotes(walletId: string): Promise { + return this.api.refreshShieldedSpentNotes(walletId) 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 { @@ -255,12 +263,12 @@ 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): 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 { @@ -279,16 +287,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, 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, 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 96e36d33..852c31c0 100644 --- a/src/renderer/src/api/types.ts +++ b/src/renderer/src/api/types.ts @@ -47,27 +47,102 @@ 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[] } + +// 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 = + | { 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 + 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 { + 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 // 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 + // L1 quotes only: narrows the funding to one Core address, or to coins the + // 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 - // 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 // 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 +154,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 @@ -207,7 +280,6 @@ export interface SendResult { txid: string amount: bigint fee: bigint - toAddress: string changeAddress: string | null peersAcked: number } diff --git a/src/renderer/src/components/modal/AssetLockFundingModal.tsx b/src/renderer/src/components/modal/AssetLockFundingModal.tsx index 54d8602e..a3ebd815 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,8 @@ interface AssetLockFundingModalProps { amountDuffs: string resume: boolean kind: AssetLockFundingKind + source?: CoreSpendSource + sourceValid?: boolean onSuccess: () => void } @@ -115,9 +117,13 @@ export default function AssetLockFundingModal({ amountDuffs, resume, kind, + source, + sourceValid = true, onSuccess, }: AssetLockFundingModalProps): React.JSX.Element | null { const { theme } = useTheme() + const sourceValidRef = useRef(sourceValid) + sourceValidRef.current = sourceValid const { status } = useAuth() const network = status?.network ?? null const [password, setPassword] = useState('') @@ -192,7 +198,7 @@ export default function AssetLockFundingModal({ const running = started && state != null && state.phase !== AssetLockFundingPhase.Done && state.phase !== AssetLockFundingPhase.Error && state.phase !== AssetLockFundingPhase.Resumable const handleConfirm = async (): Promise => { - if (!walletId || password.length === 0 || busy || started) return + if (!walletId || password.length === 0 || busy || started || !sourceValidRef.current) return setBusy(true) setPreError(null) try { @@ -202,9 +208,13 @@ export default function AssetLockFundingModal({ setBusy(false) return } + if (!sourceValidRef.current) { + setBusy(false) + return + } 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) @@ -223,6 +233,12 @@ export default function AssetLockFundingModal({ const isError = started && (state?.phase === AssetLockFundingPhase.Error || state?.phase === AssetLockFundingPhase.Resumable) const texts = TEXTS[kind] const phases = PHASE_LABELS[kind] + let modalTitle = texts.title + if (isDone) modalTitle = texts.doneTitle + else if (resume) modalTitle = texts.resumeTitle + let confirmLabel = texts.confirm + if (busy) confirmLabel = 'Starting…' + else if (resume) confirmLabel = 'Resume' return createPortal(
- {isDone ? texts.doneTitle : resume ? texts.resumeTitle : texts.title} + {modalTitle} -
@@ -302,11 +318,9 @@ export default function AssetLockFundingModal({ const label = p.key === AssetLockFundingPhase.WaitingChainLock ? lockStepLabel(state, active) : p.label return (
- {done - ? - : active - ? - :
} + {done && } + {!done && active && } + {!done && !active &&
} {label}
) diff --git a/src/renderer/src/components/modal/SendConfirmModal.tsx b/src/renderer/src/components/modal/SendConfirmModal.tsx index 70c7e291..248c6e81 100644 --- a/src/renderer/src/components/modal/SendConfirmModal.tsx +++ b/src/renderer/src/components/modal/SendConfirmModal.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' 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 { 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,10 +19,10 @@ interface SendConfirmModalProps { onClose: () => void walletId: string | null network: Network | null - toAddress: string - amountDuffs: bigint + recipients: CoreRecipient[] amountFiat?: string - fromAddress?: string + source?: CoreSpendSource + sourceValid?: boolean onSuccess: () => void } @@ -43,13 +43,16 @@ export default function SendConfirmModal({ onClose, walletId, network, - toAddress, - amountDuffs, + recipients, amountFiat, - fromAddress, + source, + sourceValid = true, onSuccess, }: SendConfirmModalProps): React.JSX.Element | null { const { theme } = useTheme() + const sourceValidRef = useRef(sourceValid) + sourceValidRef.current = sourceValid + 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) @@ -75,10 +78,10 @@ export default function SendConfirmModal({ const poll = async (): Promise => { const status: TxLockStatus | null = await API.getTxLockStatus(walletId, txid).catch(() => null) if (cancelled) return - const final: SendLockPhase | null = status?.instantLocked ? SendLockPhase.Instant - : status?.chainlocked ? SendLockPhase.Chainlocked - : status?.confirmed ? SendLockPhase.Confirmed - : null + let final: SendLockPhase | null = null + if (status?.instantLocked) final = SendLockPhase.Instant + else if (status?.chainlocked) final = SendLockPhase.Chainlocked + else if (status?.confirmed) final = SendLockPhase.Confirmed if (final) { setLockPhase(final) refreshTransactions(walletId) @@ -99,9 +102,13 @@ export default function SendConfirmModal({ const sending = phase === ConfirmModalPhase.Sending const lockFinal = lockPhase !== SendLockPhase.Waiting && lockPhase !== SendLockPhase.Fallback + let modalTitle = 'Confirm send' + if (phase === ConfirmModalPhase.Done) { + modalTitle = lockFinal ? 'Transaction confirmed' : 'Transaction sent' + } const handleConfirm = async (): Promise => { - if (!walletId || password.length === 0 || sending) return + if (!walletId || password.length === 0 || sending || !sourceValidRef.current) return setPhase(ConfirmModalPhase.Sending) setError(null) try { @@ -111,7 +118,11 @@ export default function SendConfirmModal({ setPhase(ConfirmModalPhase.Confirm) return } - const res = await API.sendTransaction(walletId, toAddress, amountDuffs, password, fromAddress) + if (!sourceValidRef.current) { + setPhase(ConfirmModalPhase.Confirm) + return + } + const res = await API.sendTransaction(walletId, recipients, password, source) setResult(res) setPhase(ConfirmModalPhase.Done) onSuccess() @@ -136,9 +147,7 @@ export default function SendConfirmModal({ >
- {phase === ConfirmModalPhase.Done - ? lockFinal ? 'Transaction confirmed' : 'Transaction sent' - : 'Confirm send'} + {modalTitle}
@@ -210,7 +225,7 @@ export default function SendConfirmModal({ - @@ -233,11 +245,9 @@ export default function ShieldedSpendModal({ return (
- {done - ? - : active - ? - :
} + {done && } + {!done && active && } + {!done && !active &&
} {p.label}
{active && p.key === ShieldedSpendPhase.Syncing && spend.total > 0 && ( diff --git a/src/renderer/src/components/modal/TransferConfirmModal.tsx b/src/renderer/src/components/modal/TransferConfirmModal.tsx index 3c673404..a41f4c04 100644 --- a/src/renderer/src/components/modal/TransferConfirmModal.tsx +++ b/src/renderer/src/components/modal/TransferConfirmModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Button, CrossIcon, Input, Text, SuccessIcon } from '../dash-ui-kit-enxtended' import { ExclamationIcon } from '../dash-ui-kit-enxtended/icons' @@ -27,6 +27,7 @@ interface TransferConfirmModalProps { successTitle: string rows: TransferConfirmRow[] run: (password: string) => Promise + sourceValid?: boolean onSuccess: () => void successNote?: string } @@ -39,10 +40,13 @@ export default function TransferConfirmModal({ successTitle, rows, run, + sourceValid = true, onSuccess, successNote, }: TransferConfirmModalProps): React.JSX.Element | null { const { theme } = useTheme() + const sourceValidRef = useRef(sourceValid) + sourceValidRef.current = sourceValid const { status } = useAuth() const network = status?.network ?? null const walletId = status?.selectedWalletId ?? null @@ -65,7 +69,7 @@ export default function TransferConfirmModal({ const sending = phase === ConfirmModalPhase.Sending const handleConfirm = async (): Promise => { - if (!walletId || password.length === 0 || sending) return + if (!walletId || password.length === 0 || sending || !sourceValidRef.current) return setPhase(ConfirmModalPhase.Sending) setError(null) try { @@ -75,6 +79,10 @@ export default function TransferConfirmModal({ setPhase(ConfirmModalPhase.Confirm) return } + if (!sourceValidRef.current) { + setPhase(ConfirmModalPhase.Confirm) + return + } const res = await run(password) setResult(res) setPhase(ConfirmModalPhase.Done) @@ -165,7 +173,7 @@ export default function TransferConfirmModal({ +
+
) @@ -426,6 +533,43 @@ export default function IdentityRegistration(): React.JSX.Element { ) + let sourceBalanceLabel = 'Shielded balance' + let sourceBalanceValue = ( + Sync notes to load + ) + if (fromKind === SourceKind.Core) { + sourceBalanceLabel = 'Available Core funds' + sourceBalanceValue = ( + {davToDash(selectedCoreDuffs)} Dash + ) + } else if (fromKind === SourceKind.PlatformAddress) { + sourceBalanceLabel = 'Available Platform funds' + sourceBalanceValue = ( + {davToDash(creditsToDuffs(availableCredits ?? 0n))} Dash + ) + } else if (availableCredits !== null) { + sourceBalanceValue = ( + {davToDash(creditsToDuffs(availableCredits))} Dash + ) + } + + let fundingFeeLabel = 'Reserved for Platform fee' + let fundingFeeValue = ( + — + ) + if (fromKind === SourceKind.Core) { + fundingFeeLabel = 'Reserved for fees' + fundingFeeValue = ( + {davToDash(totalFeeDuffs)} Dash + ) + } else if (feeError === null && feeCredits !== null) { + fundingFeeValue = ( + {davToDash(creditsToDuffs(feeCredits))} Dash + ) + } else if (feeError === null && feeLoading) { + fundingFeeValue = + } + const amountStep = ( <>
@@ -438,19 +582,38 @@ export default function IdentityRegistration(): React.JSX.Element { kind={fromKind} onKindChange={(kind) => { setFromKind(kind) - setAmount(kind === SourceKind.Shielded - ? davToDash(creditsToDuffs(POOL_IDENTITY_DENOMINATIONS[0])) - : IDENTITY_REGISTRATION_DEFAULT_AMOUNT) + if (kind === SourceKind.Shielded) { + setAmount(davToDash(creditsToDuffs(POOL_IDENTITY_DENOMINATIONS[0]))) + } else { + setAmount(IDENTITY_REGISTRATION_DEFAULT_AMOUNT) + } }} kinds={SOURCE_KINDS.filter(source => source.kind !== SourceKind.Identity)} label={"Funding source"} platformAddresses={fundedAddresses} - selectedPlatformAddress={selectedSource} - onPlatformAddressChange={setFromAddress} + selectedPlatformAddress={undefined} + onPlatformAddressChange={() => {}} + showPlatformAddress={false} identities={[]} + identitiesLoading={false} + identitiesError={null} selectedIdentity={undefined} onIdentityChange={() => {}} + onRetryIdentities={() => {}} /> + {fromKind !== SourceKind.Shielded && ( + + )} {fromKind === SourceKind.Shielded && (
@@ -486,30 +649,12 @@ export default function IdentityRegistration(): React.JSX.Element { )}
- - {fromKind === SourceKind.Core ? 'Core balance' : fromKind === SourceKind.PlatformAddress ? 'Address balance' : 'Shielded balance'} - - {fromKind === SourceKind.Core ? ( - {davToDash(balanceDuffs)} Dash - ) : availableCredits !== null ? ( - {davToDash(creditsToDuffs(availableCredits))} Dash - ) : ( - Sync notes to load - )} + {sourceBalanceLabel} + {sourceBalanceValue}
- - {fromKind === SourceKind.Core ? 'Reserved for fees' : 'Reserved for Platform fee'} - - {fromKind === SourceKind.Core ? ( - {davToDash(totalFeeDuffs)} Dash - ) : feeError === null && feeCredits !== null ? ( - {davToDash(creditsToDuffs(feeCredits))} Dash - ) : feeError === null && feeLoading ? ( - - ) : ( - — - )} + {fundingFeeLabel} + {fundingFeeValue}
{amountFiat && (
@@ -519,9 +664,16 @@ export default function IdentityRegistration(): React.JSX.Element { )}
{amountError && {amountError}} - {fromKind === SourceKind.Core && balanceError && {balanceError}} - {fromKind === SourceKind.PlatformAddress && platformAddressesError && {platformAddressesError}} - {feeError && {feeError}} + {fromKind === SourceKind.Core && (balanceError || coreAddressesError) && ( + + )} + {fromKind === SourceKind.PlatformAddress && platformAddressesError && ( + + )} + {feeError && } {fromKind === SourceKind.Core && syncIncomplete && } {fromKind === SourceKind.Shielded && ( <> @@ -536,6 +688,29 @@ export default function IdentityRegistration(): React.JSX.Element { ) + let reviewFrom = 'Your shielded balance' + let reviewFundingLabel = 'Identity denomination' + let reviewFeeLabel = 'Reserved for Platform fee' + let reviewFeeValue = ( + — + ) + if (fromKind === SourceKind.Core) { + reviewFrom = 'Dash Core (L1)' + reviewFundingLabel = 'Amount to lock' + reviewFeeLabel = 'Network fees' + reviewFeeValue = ( + {davToDash(totalFeeDuffs)} Dash + ) + } else if (fromKind === SourceKind.PlatformAddress) { + reviewFrom = 'Dash Platform' + reviewFundingLabel = 'Identity funding' + } + if (fromKind !== SourceKind.Core && feeCredits !== null) { + reviewFeeValue = ( + {formatCredits(feeCredits)} credits + ) + } + const reviewStep = ( <>
@@ -547,18 +722,16 @@ export default function IdentityRegistration(): React.JSX.Element {
From - - {fromKind === SourceKind.Core - ? 'Dash Core (L1)' - : fromKind === SourceKind.PlatformAddress - ? selectedSource?.platformAddress - : 'Your shielded balance'} - + {reviewFrom}
+ {fromKind !== SourceKind.Shielded && ( +
+ Coin control + {coinControlSummary} +
+ )}
- - {fromKind === SourceKind.Core ? 'Amount to lock' : fromKind === SourceKind.Shielded ? 'Identity denomination' : 'Identity funding'} - + {reviewFundingLabel} {fromKind === SourceKind.Core ? ( {davToDash(amountDuffs)} Dash ) : ( @@ -572,16 +745,8 @@ export default function IdentityRegistration(): React.JSX.Element {
)}
- - {fromKind === SourceKind.Core ? 'Network fees' : 'Reserved for Platform fee'} - - {fromKind === SourceKind.Core ? ( - {davToDash(totalFeeDuffs)} Dash - ) : feeCredits !== null ? ( - {formatCredits(feeCredits)} credits - ) : ( - — - )} + {reviewFeeLabel} + {reviewFeeValue}
@@ -617,6 +782,30 @@ export default function IdentityRegistration(): React.JSX.Element { submitLabel={"Register identity"} submitDisabled={!amountReady || (fromKind === SourceKind.Core && syncIncomplete) || (fromKind === SourceKind.Shielded && !prover.ready)} /> + invalidateAsyncCache('addresses', walletId)} + utxos={utxos} + utxosLoading={utxosLoading} + utxosError={utxosError} + coreSyncIncomplete={syncIncomplete} + platformAddresses={fundedAddresses} + platformAddressesLoading={platformAddressesLoading} + platformAddressesError={platformAddressesError} + onRetryPlatformAddresses={() => { void refreshPlatformAddresses(walletId) }} + shieldedNotes={[]} + identityLabel={null} + identityId={null} + platformAddress={undefined} + onRetryUtxos={retryUtxos} + onClose={() => setCoinControlOpen(false)} + onApply={setCoinControl} + /> {fromKind === SourceKind.Core && ( )} @@ -636,12 +827,13 @@ export default function IdentityRegistration(): React.JSX.Element { title={"Register identity"} successTitle={"Identity registered"} rows={[ - { label: 'From', value: selectedSource?.platformAddress ?? '', mono: true }, + { label: 'From', value: coinControlSummary }, { label: 'Identity funding', value: }, ...(feeCredits !== null ? [{ label: 'Reserved for fee', value: }] : []), { label: 'Creates', value: 'New Platform identity with 4 keys' }, ]} run={runPlatformRegistration} + sourceValid={amountReady} onSuccess={handlePlatformSuccess} /> )} @@ -657,6 +849,7 @@ export default function IdentityRegistration(): React.JSX.Element { feeCredits={feeCredits} proverReady={prover.ready} start={startShieldedRegistration} + sourceValid={amountReady} onSuccess={handleShieldedSuccess} /> )} diff --git a/src/renderer/src/components/pages/transfer/CoinControlAmountInput.tsx b/src/renderer/src/components/pages/transfer/CoinControlAmountInput.tsx new file mode 100644 index 00000000..1b1f077f --- /dev/null +++ b/src/renderer/src/components/pages/transfer/CoinControlAmountInput.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react' +import { Tooltip } from '@renderer/components/dash-ui-kit-enxtended/tooltip' +import type { CoinControlAmountInputProps } from '@renderer/types/CoinControl' +import { creditsToDash, dashToCredits, formatCredits } from '@renderer/utils/balance' + +export default function CoinControlAmountInput({id, credits, invalid, onChange}: CoinControlAmountInputProps): React.JSX.Element { + const [value, setValue] = useState(() => creditsToDash(credits)) + + useEffect(() => { + setValue(current => dashToCredits(current) === credits ? current : creditsToDash(credits)) + }, [credits]) + + const handleChange = (nextValue: string): void => { + const nextCredits = dashToCredits(nextValue) + if (nextCredits === null) return + setValue(nextValue) + onChange(nextCredits) + } + + return ( + + handleChange(event.target.value)} + aria-invalid={invalid} + className={`col-start-1 row-start-2 min-w-0 w-full rounded-[.625rem] px-3 py-2 dash-input-block dash-text-default outline-none text-[.75rem] font-mono ${invalid ? 'ring-1 ring-dash-red' : ''}`} + /> + + ) +} 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..bbb8e165 --- /dev/null +++ b/src/renderer/src/components/pages/transfer/CoinControlModal.tsx @@ -0,0 +1,583 @@ +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 CoinControlAmountInput from './CoinControlAmountInput' +import type { PlatformAddressDto } from '@renderer/api/types' +import { FIXED_IDENTITY_SOURCE_COPY, FIXED_SOURCE_COPY, INPUT_MODE_LABEL } from '@renderer/constants/coinControl' +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 { + CoinControlAddressValueProps, + CoinControlCheckRowProps, + CoinControlChoiceRowProps, + CoinControlEmptyProps, + CoinControlModalProps, + CoinControlSelection, +} from '@renderer/types/CoinControl' +import { + automaticCoinControl, + buildCoinControlInventory, + coinControlInputLabel, + coinControlSelectionTotals, + coinControlSourceKind, + isCoinControlSelectionValid, + normalizeCoinControlSelection, + outpointKey, +} from '@renderer/utils/coinControl' +import { duffsToCredits } from '@renderer/utils/balance' +import { shieldedBalancesByAddress } from '@renderer/utils/shieldedBalances' + +export default function CoinControlModal({ + isOpen, + operation, + selection, + coreAddresses, + coreAddressesLoading, + coreAddressesError, + onRetryCoreAddresses, + utxos, + utxosLoading, + utxosError, + coreSyncIncomplete, + platformAddresses, + platformAddressesLoading, + platformAddressesError, + onRetryPlatformAddresses, + 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) return + setDraft(selection) + setFilterDust(false) + setOnlySelected(false) + }, [isOpen, selection]) + + if (!isOpen || operation == null) return null + + const sourceKind = coinControlSourceKind(operation) + 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 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 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 funds = {coreAddresses, utxos, platformAddresses, shieldedNotes} + const {count: selectedCount, credits: selectedAmountCredits} = coinControlSelectionTotals(draft, funds) + const selectedItemLabel = coinControlInputLabel(sourceKind, selectedCount) + const canApply = normalizeCoinControlSelection(draft, operation) === draft + && isCoinControlSelectionValid(draft, buildCoinControlInventory(funds)) + + const chooseMode = (nextMode: CoinControlMode): void => { + setOnlySelected(false) + if (nextMode === CoinControlMode.Automatic) { + setDraft(automaticCoinControl()) + return + } + + switch (sourceKind) { + case SourceKind.Core: + if (nextMode === CoinControlMode.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: visiblePlatformAddresses[0]?.platformAddress ?? ''}) + } else { + setDraft({kind: 'platformInputs', inputs: [], feeAddress: ''}) + } + break + case SourceKind.Shielded: + if (nextMode === CoinControlMode.Address) { + setDraft({kind: 'shieldedAddress', address: visibleShieldedAddresses[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 => ( + + ) + + let sourceLoading = false + let sourceError: string | null = null + let retrySource = onRetryUtxos + if (sourceKind === SourceKind.Core) { + sourceLoading = utxosLoading || coreSyncIncomplete + sourceError = utxosError + if (mode !== CoinControlMode.Inputs) { + sourceLoading = coreAddressesLoading + sourceError = coreAddressesError + retrySource = onRetryCoreAddresses + } + } else if (sourceKind === SourceKind.PlatformAddress || operation === TransferOperation.Shield) { + sourceLoading = platformAddressesLoading + sourceError = platformAddressesError + retrySource = onRetryPlatformAddresses + } + const sourceReady = !sourceLoading && sourceError == null + + const fixed = sourceKind == null + const inputModeLabel = sourceKind == null ? 'Inputs' : INPUT_MODE_LABEL[sourceKind] + const fixedCopy = FIXED_SOURCE_COPY[operation] ?? FIXED_IDENTITY_SOURCE_COPY + let fixedValue = identityId ?? identityLabel ?? '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) { + if (outpoints.length === 0) setOnlySelected(false) + setDraft({kind: 'coreOutpoints', outpoints: [...outpoints, key]}) + } else { + 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 + : entry.platformAddress + setDraft({kind: 'platformInputs', inputs, feeAddress}) + return + } + + 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 + } + setDraft({kind: 'platformInputs', inputs, feeAddress}) + } + + const setPlatformInputCredits = (address: string, credits: bigint): void => { + 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 + if (noteIndexes.length === 0) setOnlySelected(false) + setDraft({kind: 'shieldedNotes', noteIndexes: [...noteIndexes, index]}) + } else { + const nextNoteIndexes = noteIndexes.filter(noteIndex => noteIndex !== index) + if (nextNoteIndexes.length === 0) setOnlySelected(false) + setDraft({kind: 'shieldedNotes', noteIndexes: nextNoteIndexes}) + } + } + + const apply = (): void => { + if (fixed) { + onClose() + return + } + if (!canApply || !sourceReady) return + onApply(draft) + onClose() + } + + return createPortal( +
+
+
+
+
Coin control
+ + Choose which funds this transfer may spend. + +
+ +
+ +
+ {sourceLoading && {coreSyncIncomplete && sourceKind === SourceKind.Core ? 'Wallet sync must finish before funds can be listed.' : 'Loading available funds…'}} + {!sourceLoading && sourceError && ( + + )} + {fixed ? ( +
+ {fixedCopy.title} + {sourceReady && fixedValue === identityId && identityLabel && identityLabel !== identityId && ( + {identityLabel} + )} + {sourceReady && {fixedValue}} + + {fixedCopy.description} + +
+ ) : ( + <> +
+ {modeButton(CoinControlMode.Automatic, 'Automatic')} + {modeButton(CoinControlMode.Address, 'One address')} + {modeButton(CoinControlMode.Inputs, inputModeLabel)} +
+ + {sourceKind != null && mode !== CoinControlMode.Automatic && ( +
+ {mode === CoinControlMode.Inputs && ( +
+ + Selected: {selectedCount} {selectedItemLabel} · + + {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. + +
+
+ )} + + {sourceReady && mode === CoinControlMode.Address && sourceKind === SourceKind.Core && ( +
+ {coreAddresses.length === 0 && } + {coreAddresses.length > 0 && visibleCoreAddresses.length === 0 && ( + + )} + {visibleCoreAddresses.map(entry => ( + setDraft({kind: 'coreAddress', address: entry.address})}> + + } /> + + ))} +
+ )} + + {sourceReady && mode === CoinControlMode.Address && sourceKind === SourceKind.PlatformAddress && ( +
+ {platformAddresses.length === 0 && } + {platformAddresses.length > 0 && visiblePlatformAddresses.length === 0 && ( + + )} + {visiblePlatformAddresses.map(entry => ( + setDraft({kind: 'platformAddress', address: entry.platformAddress})}> + + } /> + + ))} +
+ )} + + {sourceReady && mode === CoinControlMode.Address && sourceKind === SourceKind.Shielded && ( +
+ {shieldedAddresses.length === 0 && } + {shieldedAddresses.length > 0 && visibleShieldedAddresses.length === 0 && ( + + )} + {visibleShieldedAddresses.map(address => { + const total = shieldedBalances.get(address) ?? 0n + return ( + setDraft({kind: 'shieldedAddress', address})}> + + } /> + + ) + })} +
+ )} + + {sourceReady && mode === CoinControlMode.Inputs && sourceKind === SourceKind.Core && ( +
+ {utxos.length === 0 && } + {utxos.length > 0 && visibleUtxos.length === 0 && ( + + )} + {displayedUtxos.map(utxo => { + const key = outpointKey(utxo) + const checked = draft.kind === 'coreOutpoints' && draft.outpoints.includes(key) + return ( + toggleCoreOutpoint(key, next)}> + + · {utxo.address}{utxo.height === 0 ? ' · pending' : ''}} /> + + ) + })} +
+ )} + + {sourceReady && mode === CoinControlMode.Inputs && sourceKind === SourceKind.PlatformAddress && ( +
+ Up to {PLATFORM_INPUT_LIMIT} inputs. Set the maximum Dash available from each. + {platformAddresses.length === 0 && } + {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) + return ( +
+ togglePlatformInput(entry, checked)}> + + } /> + + {selected && ( +
+ + setPlatformInputCredits(entry.platformAddress, credits)} + /> + +
+ )} +
+ ) + })} +
+ )} + + {sourceReady && mode === CoinControlMode.Inputs && sourceKind === SourceKind.Shielded && ( +
+ Choose up to {SHIELDED_NOTE_LIMIT} notes. + {shieldedNotes.length === 0 && } + {shieldedNotes.length > 0 && visibleShieldedNotes.length === 0 && ( + + )} + {displayedShieldedNotes.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}} /> + + ) + })} +
+ )} + + )} +
+ +
+ {!fixed && ( + + )} + + +
+
+
, + document.body, + ) +} + +function Empty({text}: CoinControlEmptyProps): React.JSX.Element { + return
{text}
+} + +function AddressValue({address, detail}: CoinControlAddressValueProps): React.JSX.Element { + return ( + + {address} + {detail} + + ) +} + +function ChoiceRow({checked, onChange, children}: CoinControlChoiceRowProps): React.JSX.Element { + return ( + + ) +} + +function CheckRow({checked, onChange, children, disabled = false, bare = false}: CoinControlCheckRowProps): 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/EndpointPicker.tsx b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx index 57559810..ed3ee2da 100644 --- a/src/renderer/src/components/pages/transfer/EndpointPicker.tsx +++ b/src/renderer/src/components/pages/transfer/EndpointPicker.tsx @@ -41,38 +41,72 @@ function KindDropdown({kinds, selected, onSelect}: KindDropdownProps): React.JSX interface IdentitySelectProps { identities: IdentityApiDto[] + loading: boolean + error: string | null selected: IdentityApiDto | undefined onSelect: (identifier: string) => void + onRetry: () => void } -function IdentitySelect({identities, selected, onSelect}: IdentitySelectProps): React.JSX.Element { +function IdentitySelect({identities, loading, error, selected, onSelect, onRetry}: IdentitySelectProps): React.JSX.Element { const [open, setOpen] = useState(false) const ref = useRef(null) useClickOutside(ref, () => setOpen(false)) + let content: React.JSX.Element + if (selected) { + content = ( +
+ + {selected.alias ?? selected.identifier} + + + + +
+ ) + } else if (loading) { + content = Loading identities… + } else if (error) { + content = Identities + } else { + content = No identities in this wallet + } + + let action: React.JSX.Element | null = null + if (error) { + action = Try again + } else if (identities.length > 0) { + action = ( + + ) + } else if (!loading) { + action = Try again + } + + const handleClick = (): void => { + if (error || identities.length === 0) { + if (!loading) onRetry() + return + } + setOpen(value => !value) + } + return (
- {open && ( + {open && !loading && !error && (
{identities.map(identity => ( + )} + {kind === SourceKind.PlatformAddress && showPlatformAddress && !platformAddressesLoading && !platformAddressesError && ( )} {kind === SourceKind.Identity && ( - + )}
) diff --git a/src/renderer/src/components/pages/transfer/TransferHub.tsx b/src/renderer/src/components/pages/transfer/TransferHub.tsx index 7d98606b..bac488ac 100644 --- a/src/renderer/src/components/pages/transfer/TransferHub.tsx +++ b/src/renderer/src/components/pages/transfer/TransferHub.tsx @@ -1,13 +1,14 @@ 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 ProverPill from "@renderer/components/pages/shielded/ProverPill"; import Spinner from "@renderer/components/ui/Spinner"; +import { toast } from "@renderer/components/ui/Toast"; import { useAuth } from "@renderer/contexts/AuthContext"; import { useConnectionModeContext } from "@renderer/contexts/ConnectionModeContext"; import { useFiat } from "@renderer/hooks/useFiat"; @@ -15,22 +16,31 @@ import { useWalletBalance, refreshBalance } from "@renderer/hooks/useWalletBalan import { refreshTransactions } from "@renderer/hooks/useWalletTransactions"; import { usePlatformAddresses, refreshPlatformAddresses } from "@renderer/hooks/usePlatformAddresses"; import { useAdresses } from "@renderer/hooks/useAdresses"; -import { useIdentities, prefetchIdentities } from "@renderer/hooks/useIdentities"; +import { useIdentities, prefetchIdentities, refreshIdentities } from "@renderer/hooks/useIdentities"; import { useShieldedStatus, useShieldedSyncState } from "@renderer/hooks/useShielded"; import { useOperationFee } from "@renderer/hooks/useOperationFee"; -import { creditsToDuffs, davToDash, davToDashCompact, dashToDuffs, duffsToCredits } from "@renderer/utils/balance"; +import { useErrorToast } from "@renderer/hooks/useErrorToast"; +import { useWalletUtxos } from "@renderer/hooks/useWalletUtxos"; +import { invalidateAsyncCache } from "@renderer/hooks/useAsyncWithCache"; +import { compareBigIntsDescending, creditsToDuffs, davToDash, davToDashCompact, dashToDuffs, duffsToCredits } from "@renderer/utils/balance"; import { isValidDashAddress } from "@renderer/utils/address"; import { isValidPlatformAddress } from "@renderer/utils/platformAddress"; import { isLikelyShieldedAddress } from "@renderer/utils/shieldedAddress"; -import { shieldedBalancesByAddress } from "@renderer/utils/shieldedBalances"; import { amountErrorFor } from "@renderer/utils/amountValidation"; +import { getErrorMessage } from "@renderer/utils/error"; import { isUnfinishedAssetLockFunding } from "@renderer/utils/identityRegistration"; -import { - specificSourceKindForOperation, - updateSpecificSourceAddress, - updateSpecificSourceEnabled, -} from "@renderer/utils/specificSource"; import { clearSendDraft, getOrCreateSendDraft, saveSendDraft } from "@renderer/utils/sendDraft"; +import { + automaticCoinControl, + buildCoinControlInventory, + coinControlSelectionSummary, + coinControlSelectionTotals, + isCoinControlSelectionValid, + normalizeCoinControlSelection, + toCoreSpendSource, + toPlatformSpendSource, + toShieldedSpendSource, +} from "@renderer/utils/coinControl"; import { DESTINATION_KINDS, resolveOperation, @@ -50,15 +60,16 @@ import { AssetLockFundingKind } from "@renderer/enums/AssetLockFundingKind"; import { API } from "@renderer/api"; import { AssetLockFundingState, PlatformAddressDto, ShieldedSpendState } 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 { COIN_CONTROL_INVALID_MESSAGE } from "@renderer/constants/coinControl"; import { sendPageData, WITHDRAWAL_SUCCESS_NOTE } from "@renderer/constants"; +import { DESTINATION_PLACEHOLDERS, INVALID_DESTINATION_MESSAGES, OPERATION_FUNDING_KINDS, SHIELDED_DESTINATION_LABELS, UNFINISHED_FUNDING_LABELS } from "@renderer/constants/sendPages"; 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"; @@ -74,6 +85,7 @@ export default function TransferHub(): React.JSX.Element { function WalletTransferHub(): React.JSX.Element { const { status } = useAuth() + const { syncIncomplete } = useConnectionModeContext() const walletId = status?.selectedWalletId ?? null const network = status?.network ?? null @@ -81,7 +93,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, coinControl } = draft const updateDraft = (update: (current: SendDraft) => SendDraft): void => { const next = update(draftRef.current) draftRef.current = next @@ -95,15 +107,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 setCoinControl = (coinControl: CoinControlSelection): void => updateDraft(current => ({ ...current, coinControl })) + const [coinControlOpen, setCoinControlOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) const [notesUnlockOpen, setNotesUnlockOpen] = useState(false) const [wizardKey, setWizardKey] = useState(0) + const { utxos, loading: utxosLoading, error: utxosError, retry: retryUtxos } = useWalletUtxos(wizardKey) const [fundingRefresh, setFundingRefresh] = useState(0) const [resumableFunding, setResumableFunding] = useState(null) const [resumeOpen, setResumeOpen] = useState(false) @@ -119,7 +128,10 @@ function WalletTransferHub(): React.JSX.Element { if (dead) return setResumableFunding(isUnfinishedAssetLockFunding(state.phase) ? state : null) }) - .catch(() => {}) + .catch(error => { + if (dead) return + toast.error(`**Could not check funding progress** ${getErrorMessage(error)}`) + }) return () => { dead = true } }, [walletId, wizardKey, fundingRefresh]) @@ -139,22 +151,23 @@ function WalletTransferHub(): React.JSX.Element { } } - const { syncIncomplete } = useConnectionModeContext() const { format: formatFiat, rateReady } = useFiat() const { balance } = useWalletBalance(walletId ?? undefined) - const { receiving, change } = useAdresses(walletId ?? undefined) - const { platformAddresses } = usePlatformAddresses(walletId ?? undefined) - const { identities } = useIdentities(walletId ?? undefined) + const { receiving, change, loading: coreAddressesLoading, err: coreAddressesError } = useAdresses(walletId ?? undefined) + const { platformAddresses, loading: platformAddressesLoading, err: platformAddressesError } = usePlatformAddresses(walletId ?? undefined) + const { identities, loading: identitiesLoading, err: identitiesError } = useIdentities(walletId ?? undefined) const shieldedSync = useShieldedSyncState(walletId) const prover = useShieldedStatus() + useErrorToast(utxosError) + useErrorToast(coreAddressesError) + useErrorToast(platformAddressesError) + useErrorToast(identitiesError) + useErrorToast(shieldedSync.error) const operation = resolveOperation(fromKind, toKind) 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], @@ -187,76 +200,144 @@ function WalletTransferHub(): React.JSX.Element { const coreAddresses = useMemo( () => [...receiving, ...change] .filter(a => a.balance > 0n) - .sort((a, b) => (a.balance < b.balance ? 1 : a.balance > b.balance ? -1 : 0)), + .sort((a, b) => compareBigIntsDescending(a.balance, b.balance)), [receiving, change], ) - const selectedCoreAddress = coreAddresses.find(a => a.address === specificSourcePreferences.addresses[SourceKind.Core]) ?? coreAddresses[0] - const coreSpecificAddress = operation === TransferOperation.CoreSend && useSpecificSource ? selectedCoreAddress : 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)), + .sort((a, b) => compareBigIntsDescending(a.amount, b.amount)), [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 shieldedSpecificNotes = useMemo( - () => shieldedSpendOperation && useSpecificSource && selectedShieldedAddress != null - ? spendableNotes.filter(n => n.address === selectedShieldedAddress) - : undefined, - [shieldedSpendOperation, useSpecificSource, selectedShieldedAddress, spendableNotes], + const coinControlFunds = useMemo(() => ({ + coreAddresses, utxos, platformAddresses: fundedAddresses, shieldedNotes: spendableNotes, + }), [coreAddresses, utxos, fundedAddresses, spendableNotes]) + const coinControlInventory = useMemo(() => buildCoinControlInventory(coinControlFunds), [coinControlFunds]) + const appliedCoinControl = useMemo( + () => normalizeCoinControlSelection(coinControl, operation), + [coinControl, operation], ) + const coinControlLoading = { + automatic: false, + coreAddress: coreAddressesLoading, + coreOutpoints: utxosLoading || syncIncomplete, + platformAddress: platformAddressesLoading, + platformInputs: platformAddressesLoading, + shieldedAddress: shieldedSync.phase !== ShieldedSyncPhase.Done && shieldedSync.phase !== ShieldedSyncPhase.Error, + shieldedNotes: shieldedSync.phase !== ShieldedSyncPhase.Done && shieldedSync.phase !== ShieldedSyncPhase.Error, + }[appliedCoinControl.kind] + const sourceInventoryError = { + [SourceKind.Core]: coreAddressesError ?? (appliedCoinControl.kind === 'coreOutpoints' ? utxosError : null), + [SourceKind.PlatformAddress]: platformAddressesError, + [SourceKind.Identity]: identitiesError, + [SourceKind.Shielded]: shieldedSync.error, + }[fromKind] + const coinControlValid = !coinControlLoading && !sourceInventoryError + && isCoinControlSelectionValid(appliedCoinControl, coinControlInventory) - const balanceDuffs = coreSpecificAddress ? coreSpecificAddress.balance : balance.dash.amount + useEffect(() => { + if (!coinControlLoading && !sourceInventoryError && !coinControlValid) toast.error(COIN_CONTROL_INVALID_MESSAGE) + }, [coinControlLoading, sourceInventoryError, coinControlValid]) + + useEffect(() => { + if (appliedCoinControl !== coinControl) setCoinControl(appliedCoinControl) + }, [appliedCoinControl, coinControl]) + + const coreSpendSource = useMemo(() => toCoreSpendSource(appliedCoinControl, utxos), [appliedCoinControl, utxos]) + const platformSource = useMemo(() => toPlatformSpendSource(appliedCoinControl), [appliedCoinControl]) + const shieldedSpendSource = useMemo( + () => toShieldedSpendSource(appliedCoinControl, spendableNotes), + [appliedCoinControl, spendableNotes], + ) + const selectedTotals = coinControlSelectionTotals(appliedCoinControl, coinControlFunds) + 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] + } + + let balanceDuffs = balance.dash.amount + if (appliedCoinControl.kind === 'coreOutpoints' || appliedCoinControl.kind === 'coreAddress') { + balanceDuffs = selectedTotals.duffs + } 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.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' || appliedCoinControl.kind === 'platformAddress') { + availableCredits = selectedTotals.credits + } 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 = shieldedBalance + if (appliedCoinControl.kind === 'shieldedNotes' || appliedCoinControl.kind === 'shieldedAddress') { + availableCredits = selectedTotals.credits + } + } 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) - : toKind === DestinationKind.PlatformAddress ? isValidPlatformAddress(trimmedTo, network ?? undefined) - : toKind === DestinationKind.Identity ? isLikelyIdentityId(trimmedTo) - : toKind === DestinationKind.NewIdentity ? true - : isLikelyShieldedAddress(trimmedTo) + const amountCredits = isCoreOperation ? 0n : duffsToCredits(amountDuffs) + + let destinationValid = false + switch (toKind) { + case DestinationKind.CoreAddress: + destinationValid = isValidDashAddress(trimmedTo, network ?? undefined) + break + case DestinationKind.PlatformAddress: + destinationValid = isValidPlatformAddress(trimmedTo, network ?? undefined) + break + case DestinationKind.Identity: + destinationValid = isLikelyIdentityId(trimmedTo) + break + case DestinationKind.NewIdentity: + destinationValid = true + break + case DestinationKind.Shielded: + destinationValid = isLikelyShieldedAddress(trimmedTo) + break + } - const { feeCredits, feeDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr } = useOperationFee(walletId, operation, { + const { feeCredits, feeDuffs, maxDuffs, maxPerTx, noteLimit, loading: feeLoading, err: feeErr, retry: retryFee } = useOperationFee(walletId, coinControlValid ? operation : null, { destinationValid, recipient: trimmedTo, amountCredits, - sourceAddress: selectedSource?.platformAddress ?? null, + amountDuffs: isCoreOperation ? amountDuffs : null, + coreSource: coreSpendSource ?? null, + platformSource, identityId: selectedIdentity?.identifier ?? null, - noteIndexes: shieldedSpecificNotes?.map(note => note.index) ?? null, + shieldedSource: shieldedSpendSource ?? null, }) + useErrorToast(feeErr) // An L1 send pays its fee on top of the amount; an L1 -> L2 transfer locks the // 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 @@ -271,41 +352,44 @@ function WalletTransferHub(): React.JSX.Element { setAmount(davToDash(value)) } - const sourceReady = - fromKind === SourceKind.Core ? true - : fromKind === SourceKind.PlatformAddress ? selectedSource != null - : fromKind === SourceKind.Identity ? selectedIdentity != null - : true + const sourceReady = { + [SourceKind.Core]: true, + [SourceKind.PlatformAddress]: selectedSource != null, + [SourceKind.Identity]: selectedIdentity != null, + [SourceKind.Shielded]: true, + }[fromKind] const selfSend = - (operation === TransferOperation.AddressFundsTransfer && destinationValid && selectedSource != null && trimmedTo === selectedSource.platformAddress) + (operation === TransferOperation.AddressFundsTransfer && destinationValid + && fundingAddresses.includes(trimmedTo)) || (operation === TransferOperation.IdentityToIdentity && destinationValid && selectedIdentity != null && trimmedTo === selectedIdentity.identifier) - const destinationError = toKind === DestinationKind.NewIdentity || trimmedTo.length === 0 - ? null - : !destinationValid - ? (toKind === DestinationKind.CoreAddress ? `Enter a valid Dash ${network ?? ''} address.` - : toKind === DestinationKind.PlatformAddress ? `Enter a valid Platform ${network ?? ''} address.` - : toKind === DestinationKind.Identity ? 'Enter a valid identity identifier.' - : 'Enter a valid shielded address.') - : selfSend - ? (operation === TransferOperation.IdentityToIdentity ? 'Recipient must be different from the source identity.' : 'Recipient must be different from the source address.') - : null + let destinationError: string | null = null + if (toKind !== DestinationKind.NewIdentity && trimmedTo.length > 0) { + if (!destinationValid) { + destinationError = INVALID_DESTINATION_MESSAGES[toKind].replace('{network}', network ?? '') + } else if (selfSend) { + destinationError = 'Recipient must be different from the source address.' + if (operation === TransferOperation.IdentityToIdentity) { + destinationError = 'Recipient must be different from the source identity.' + } + } + } const needsAck = operation === TransferOperation.ShieldedWithdrawal const destinationReady = destinationValid && !selfSend && (!needsAck || acked) const coreSourceGated = fromKind === SourceKind.Core && syncIncomplete - const routeReady = operation != null && sourceReady && destinationReady && !coreSourceGated + const routeReady = operation != null && sourceReady && destinationReady && !coreSourceGated && coinControlValid 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 && (maxPerTx === null || amountCredits <= maxPerTx) && (operation !== TransferOperation.IdentityCreateFromShielded || isPoolIdentityDenomination(amountCredits)) - const canSubmit = routeReady && amountReady + const canSubmit = routeReady && amountReady && !feeLoading && !feeErr const amountFiat = rateReady && amountDuffs > 0n ? formatFiat(amountDuffs) : undefined @@ -319,7 +403,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) { @@ -331,19 +415,14 @@ function WalletTransferHub(): React.JSX.Element { setAmount(davToDash(creditsToDuffs(spendable > 0n ? spendable : 0n))) } - const destinationPlaceholder = - toKind === DestinationKind.CoreAddress ? (network === 'mainnet' ? 'X… (Dash address)' : 'y… (Dash address)') - : toKind === DestinationKind.PlatformAddress ? (network === 'mainnet' ? 'dash1…' : 'tdash1…') - : toKind === DestinationKind.Identity ? 'Identity identifier' - : 'shielded address' + const destinationPlaceholder = DESTINATION_PLACEHOLDERS[toKind][network ?? 'testnet'] const amountError = amountErrorFor({ isCoreOperation, amount, - totalFeeDuffs, + coreMaxDuffs, operation, amountDuffs, - balanceDuffs, amountCredits, minCredits, availableCredits, @@ -351,10 +430,17 @@ function WalletTransferHub(): React.JSX.Element { maxPerTx, noteLimit, }) - const fieldError = amountError ?? feeErr + + const reloadIdentities = (): void => { + if (!walletId) return + void refreshIdentities(walletId) + } + + let coinControlSummary = coinControlSelectionSummary(appliedCoinControl, selectedTotals) + if (operation === TransferOperation.Shield) coinControlSummary = 'Fixed address' const resetForm = (): void => { - const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false } + const resetDraft = { ...draftRef.current, toValue: '', amount: '', acked: false, coinControl: automaticCoinControl() } draftRef.current = resetDraft setDraftState(resetDraft) if (walletId) clearSendDraft(walletId) @@ -369,44 +455,42 @@ function WalletTransferHub(): React.JSX.Element { <> { setFromKind(k); setAcked(false) }} + onKindChange={k => { + setFromKind(k) + setAcked(false) + if (k === SourceKind.Identity && identities.length === 0) reloadIdentities() + }} platformAddresses={fundedAddresses} selectedPlatformAddress={selectedSource} onPlatformAddressChange={setFromAddress} + platformAddressesLoading={platformAddressesLoading} + platformAddressesError={platformAddressesError} + onRetryPlatformAddresses={() => { if (walletId) void refreshPlatformAddresses(walletId) }} + showPlatformAddress={operation === TransferOperation.Shield} identities={identities} + identitiesLoading={identitiesLoading} + identitiesError={identitiesError} selectedIdentity={selectedIdentity} onIdentityChange={setFromIdentity} + onRetryIdentities={reloadIdentities} /> - {specificSourceKind != null && ( -
- setSpecificSourcePreferences(current => - updateSpecificSourceEnabled(current, enabled))} - label={Send from a specific address} - /> - {useSpecificSource && operation === TransferOperation.CoreSend && ( - setSpecificSourcePreferences(current => - updateSpecificSourceAddress(current, SourceKind.Core, address))} - /> - )} - {useSpecificSource && shieldedSpendOperation && ( - <> - setSpecificSourcePreferences(current => - updateSpecificSourceAddress(current, SourceKind.Shielded, address))} - /> - setNotesUnlockOpen(true)} syncing={notesSyncing} /> - - )} -
+ {operation != null && fromKind !== SourceKind.Identity && ( + + )} + + {fromKind === SourceKind.Shielded && ( + setNotesUnlockOpen(true)} syncing={notesSyncing} /> )} {toKind === DestinationKind.CoreAddress && operation === TransferOperation.CoreSend ? ( @@ -522,6 +606,29 @@ function WalletTransferHub(): React.JSX.Element { ) + let sourceBalanceDisplay = ( + Sync notes on the Shielded page to see your balance + ) + if (isCoreOperation) { + const exceedsBalance = amountDuffs > 0n && amountDuffs > balanceDuffs + sourceBalanceDisplay = ( + + {exceedsBalance ? 'Amount exceeds balance' : `Balance: ${davToDashCompact(balanceDuffs)} Dash`} + + ) + } else if (availableCredits !== null) { + sourceBalanceDisplay = Available: + } + + let feeDisplay = — + if (isCoreOperation) { + feeDisplay = {davToDash(totalFeeDuffs)} Dash + } else if (feeErr === null && feeCredits !== null) { + feeDisplay = + } else if (feeErr === null && feeLoading) { + feeDisplay = + } + const amountStep = (
{operation === TransferOperation.IdentityCreateFromShielded && ( @@ -553,50 +660,47 @@ function WalletTransferHub(): React.JSX.Element { disabled={sliderMaxAmount === 0n} /> )} - {fieldError && ( + {amountError && (
- {fieldError} + {amountError}
)} + {feeErr && }
- {isCoreOperation ? ( - 0n && amountDuffs > balanceDuffs ? "red" : "brand"} opacity={amountDuffs > 0n && amountDuffs > balanceDuffs ? 100 : 50}> - {amountDuffs > 0n && amountDuffs > balanceDuffs ? 'Amount exceeds balance' : `Balance: ${davToDashCompact(balanceDuffs)} Dash`} - - ) : availableCredits !== null ? ( - - Available: - - ) : ( - Sync notes on the Shielded page to see your balance - )} + {sourceBalanceDisplay} {amountFiat && ≈ {amountFiat}}
- {isCoreOperation ? ( -
- Network fee - {davToDash(totalFeeDuffs)} Dash -
- ) : ( -
- Reserved for fee - {feeErr === null && feeCredits !== null ? ( - - ) : feeErr === null && feeLoading ? ( - - ) : ( - — - )} -
- )} +
+ {isCoreOperation ? 'Network fee' : 'Reserved for fee'} + {feeDisplay} +
) - 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 @@ -665,24 +769,34 @@ 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, + [{ 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, noteIndexes) + return API.startShieldedWithdrawal(walletId, trimmedTo, amountCredits, password, shieldedSpendSource) } const runPlatformOperation = (password: string) => { 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, + [{ 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) @@ -691,7 +805,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, @@ -724,10 +838,7 @@ function WalletTransferHub(): React.JSX.Element {
- {resumableFunding.kind === AssetLockFundingKind.Shielded ? 'Unfinished L1 shielding' - : resumableFunding.kind === AssetLockFundingKind.Identity ? 'Unfinished identity registration' - : resumableFunding.kind === AssetLockFundingKind.IdentityTopUp ? 'Unfinished identity top-up' - : 'Unfinished Platform address funding'} + {UNFINISHED_FUNDING_LABELS[resumableFunding.kind]} {resumableFunding.amountDuffs ?? ''} duffs → {resumableFunding.kind === AssetLockFundingKind.Identity ? 'new identity' : (resumableFunding.toPlatformAddress ?? '')} @@ -769,24 +880,49 @@ function WalletTransferHub(): React.JSX.Element { key={wizardKey} steps={[ { label: 'From & To', content: routeStep, canAdvance: routeReady }, - { label: 'Amount', content: amountStep, canAdvance: amountReady }, + { label: 'Amount', content: amountStep, canAdvance: canSubmit }, { label: 'Confirm', content: confirmStep }, ]} - onSubmit={() => setConfirmOpen(true)} + onSubmit={() => { if (canSubmit) setConfirmOpen(true) }} submitLabel={info?.submitLabel ?? 'Send'} submitDisabled={!canSubmit} /> + { if (walletId) invalidateAsyncCache('addresses', walletId) }} + utxos={utxos} + utxosLoading={utxosLoading} + utxosError={utxosError} + coreSyncIncomplete={syncIncomplete} + platformAddresses={fundedAddresses} + platformAddressesLoading={platformAddressesLoading} + platformAddressesError={platformAddressesError} + onRetryPlatformAddresses={() => { if (walletId) void refreshPlatformAddresses(walletId) }} + shieldedNotes={spendableNotes} + identityLabel={selectedIdentity?.alias ?? null} + identityId={selectedIdentity?.identifier ?? null} + platformAddress={selectedSource} + onRetryUtxos={retryUtxos} + onClose={() => setCoinControlOpen(false)} + onApply={setCoinControl} + /> + {operation === TransferOperation.CoreSend && ( setConfirmOpen(false)} walletId={walletId} network={network} - toAddress={trimmedTo} - amountDuffs={amountDuffs} + recipients={[{ address: trimmedTo, amountDuffs }]} amountFiat={amountFiat} - fromAddress={coreSpecificAddress?.address} + source={coreSpendSource} + sourceValid={canSubmit} onSuccess={() => { resetForm() if (walletId) { @@ -803,6 +939,7 @@ function WalletTransferHub(): React.JSX.Element { onClose={() => setConfirmOpen(false)} walletId={walletId} fromAddress={selectedSource?.platformAddress ?? ''} + sourceValid={canSubmit} toAddress={trimmedTo} amountCredits={amountCredits.toString()} feeCredits={feeCredits} @@ -817,12 +954,13 @@ function WalletTransferHub(): React.JSX.Element { onClose={() => setConfirmOpen(false)} walletId={walletId} title={info?.title ?? 'Send'} - toLabel={operation === TransferOperation.ShieldedTransfer ? 'To (shielded)' : operation === TransferOperation.Unshield ? 'To (Platform)' : operation === TransferOperation.IdentityCreateFromShielded ? 'Creates' : 'To (Core L1)'} + toLabel={SHIELDED_DESTINATION_LABELS[operation ?? TransferOperation.ShieldedWithdrawal] ?? 'To (Core L1)'} toValue={operation === TransferOperation.IdentityCreateFromShielded ? 'New Platform identity with 6 keys' : trimmedTo} amountCredits={amountCredits.toString()} feeCredits={feeCredits} proverReady={prover.ready} start={startShieldedSpend} + sourceValid={canSubmit} onSuccess={resetForm} successNote={operation === TransferOperation.ShieldedWithdrawal ? WITHDRAWAL_SUCCESS_NOTE : undefined} /> @@ -836,7 +974,9 @@ function WalletTransferHub(): React.JSX.Element { toPlatformAddress={operation === TransferOperation.IdentityRegister ? '' : trimmedTo} amountDuffs={amountDuffs.toString()} resume={false} - kind={operation === TransferOperation.AssetLockShield ? AssetLockFundingKind.Shielded : operation === TransferOperation.IdentityRegister ? AssetLockFundingKind.Identity : operation === TransferOperation.IdentityTopUpL1 ? AssetLockFundingKind.IdentityTopUp : AssetLockFundingKind.Address} + kind={OPERATION_FUNDING_KINDS[operation] ?? AssetLockFundingKind.Address} + source={coreSpendSource} + sourceValid={canSubmit} onSuccess={() => { resetForm() if (walletId) { @@ -884,6 +1024,7 @@ function WalletTransferHub(): React.JSX.Element { {label: 'To', value: toDisplay, mono: true}, ]} run={runPlatformOperation} + sourceValid={canSubmit} onSuccess={resetForm} successNote={operation === TransferOperation.AddressWithdrawal || operation === TransferOperation.IdentityWithdrawal ? WITHDRAWAL_SUCCESS_NOTE : undefined} /> diff --git a/src/renderer/src/components/ui/CreditsAmount.tsx b/src/renderer/src/components/ui/CreditsAmount.tsx index 156acaac..ac9e7f32 100644 --- a/src/renderer/src/components/ui/CreditsAmount.tsx +++ b/src/renderer/src/components/ui/CreditsAmount.tsx @@ -1,21 +1,12 @@ import DashBigNumber from '@renderer/components/ui/DashBigNumber' import { useFiat } from '@renderer/hooks/useFiat' -import { creditsToDuffs, davToDash, formatCompactCredits, formatCredits } from '@renderer/utils/balance' - -interface CreditsAmountProps { - credits: bigint - compact?: boolean - unit?: string | null - showFiat?: boolean - align?: 'start' | 'end' | 'center' - amountClassName?: string - unitClassName?: string - className?: string -} +import type { CreditsAmountProps } from '@renderer/types/Amount' +import { creditsToDash, creditsToDuffs, davToDash, formatCompactCredits, formatCredits } from '@renderer/utils/balance' export default function CreditsAmount({ credits, compact = false, + exact = false, unit = 'credits', showFiat = true, align = 'start', @@ -28,8 +19,15 @@ export default function CreditsAmount({ const fiat = showFiat && rateReady ? formatFiat(duffs) : null const face = 'col-start-1 row-start-1 whitespace-nowrap transition-[opacity,transform] duration-200 motion-reduce:transition-none' - const items = align === 'end' ? 'items-end' : align === 'center' ? 'items-center' : 'items-start' - const justifyItems = align === 'end' ? 'justify-items-end' : align === 'center' ? 'justify-items-center' : 'justify-items-start' + let items = 'items-start' + let justifyItems = 'justify-items-start' + if (align === 'end') { + items = 'items-end' + justifyItems = 'justify-items-end' + } else if (align === 'center') { + items = 'items-center' + justifyItems = 'justify-items-center' + } return ( - {davToDash(duffs)} + {exact ? creditsToDash(credits) : davToDash(duffs)} {' Dash'} > = { + [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.', + }, +} + +export const FIXED_IDENTITY_SOURCE_COPY: CoinControlFixedSourceCopy = { + title: 'Selected identity', + description: 'Identity operations spend the selected identity balance. Change it in the From field.', +} + +export const INPUT_MODE_LABEL: Record = { + [SourceKind.Core]: 'UTXOs', + [SourceKind.PlatformAddress]: 'Inputs', + [SourceKind.Identity]: 'Inputs', + [SourceKind.Shielded]: 'Notes', +} + +export const INPUT_ITEM_LABELS: Record = { + [SourceKind.Core]: {singular: 'UTXO', plural: 'UTXOs'}, + [SourceKind.PlatformAddress]: {singular: 'input', plural: 'inputs'}, + [SourceKind.Identity]: {singular: 'input', plural: 'inputs'}, + [SourceKind.Shielded]: {singular: 'note', plural: 'notes'}, +} diff --git a/src/renderer/src/constants/core.ts b/src/renderer/src/constants/core.ts new file mode 100644 index 00000000..88db2ef8 --- /dev/null +++ b/src/renderer/src/constants/core.ts @@ -0,0 +1 @@ +export const CORE_DUST_FILTER_DUFFS = 100_000n diff --git a/src/renderer/src/constants/platform.ts b/src/renderer/src/constants/platform.ts new file mode 100644 index 00000000..c65d3bc2 --- /dev/null +++ b/src/renderer/src/constants/platform.ts @@ -0,0 +1,3 @@ +// Consensus caps the inputs one address-funded transition may carry. +export const PLATFORM_INPUT_LIMIT = 16 +export const PLATFORM_DUST_FILTER_CREDITS = 100_000_000n diff --git a/src/renderer/src/constants/sendPages.ts b/src/renderer/src/constants/sendPages.ts index e964d82b..dfb8beb1 100644 --- a/src/renderer/src/constants/sendPages.ts +++ b/src/renderer/src/constants/sendPages.ts @@ -1,4 +1,44 @@ -import { OperationFee } from '../api/types' +import type { Network, OperationFee } from '../api/types' +import { AssetLockFundingKind } from '../enums/AssetLockFundingKind' +import { DestinationKind } from '../enums/DestinationKind' +import { TransferOperation } from '../enums/TransferOperation' + +export const DESTINATION_PLACEHOLDERS: Record> = { + [DestinationKind.CoreAddress]: {mainnet: 'X… (Dash address)', testnet: 'y… (Dash address)'}, + [DestinationKind.PlatformAddress]: {mainnet: 'dash1…', testnet: 'tdash1…'}, + [DestinationKind.Identity]: {mainnet: 'Identity identifier', testnet: 'Identity identifier'}, + [DestinationKind.Shielded]: {mainnet: 'shielded address', testnet: 'shielded address'}, + [DestinationKind.NewIdentity]: {mainnet: 'shielded address', testnet: 'shielded address'}, +} + +export const INVALID_DESTINATION_MESSAGES: Record = { + [DestinationKind.CoreAddress]: 'Enter a valid Dash {network} address.', + [DestinationKind.PlatformAddress]: 'Enter a valid Platform {network} address.', + [DestinationKind.Identity]: 'Enter a valid identity identifier.', + [DestinationKind.Shielded]: 'Enter a valid shielded address.', + [DestinationKind.NewIdentity]: '', +} + +export const UNFINISHED_FUNDING_LABELS: Record = { + [AssetLockFundingKind.Address]: 'Unfinished Platform address funding', + [AssetLockFundingKind.Shielded]: 'Unfinished L1 shielding', + [AssetLockFundingKind.Identity]: 'Unfinished identity registration', + [AssetLockFundingKind.IdentityTopUp]: 'Unfinished identity top-up', +} + +export const SHIELDED_DESTINATION_LABELS: Partial> = { + [TransferOperation.ShieldedTransfer]: 'To (shielded)', + [TransferOperation.Unshield]: 'To (Platform)', + [TransferOperation.IdentityCreateFromShielded]: 'Creates', + [TransferOperation.ShieldedWithdrawal]: 'To (Core L1)', +} + +export const OPERATION_FUNDING_KINDS: Partial> = { + [TransferOperation.AssetLockFunding]: AssetLockFundingKind.Address, + [TransferOperation.AssetLockShield]: AssetLockFundingKind.Shielded, + [TransferOperation.IdentityRegister]: AssetLockFundingKind.Identity, + [TransferOperation.IdentityTopUpL1]: AssetLockFundingKind.IdentityTopUp, +} export interface TransferPageType { header: { @@ -28,7 +68,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/constants/shielded.ts b/src/renderer/src/constants/shielded.ts index 1548cb95..9508a7d6 100644 --- a/src/renderer/src/constants/shielded.ts +++ b/src/renderer/src/constants/shielded.ts @@ -9,3 +9,7 @@ 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 +export const SHIELDED_DUST_FILTER_CREDITS = 100_000_000n + +// One Orchard action per note spent, so the note cap is the bundle's. +export const SHIELDED_NOTE_LIMIT = 6 diff --git a/src/renderer/src/enums/CoinControlMode.ts b/src/renderer/src/enums/CoinControlMode.ts new file mode 100644 index 00000000..92874549 --- /dev/null +++ b/src/renderer/src/enums/CoinControlMode.ts @@ -0,0 +1,5 @@ +export enum CoinControlMode { + Automatic = 'automatic', + Address = 'address', + Inputs = 'inputs', +} diff --git a/src/renderer/src/hooks/useErrorToast.ts b/src/renderer/src/hooks/useErrorToast.ts new file mode 100644 index 00000000..09940662 --- /dev/null +++ b/src/renderer/src/hooks/useErrorToast.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef } from 'react' +import { toast } from '@renderer/components/ui/Toast' + +export function useErrorToast(error: string | null): void { + const previous = useRef(null) + useEffect(() => { + if (error && error !== previous.current) toast.error(error) + previous.current = error + }, [error]) +} diff --git a/src/renderer/src/hooks/useOperationFee.ts b/src/renderer/src/hooks/useOperationFee.ts index 0c0210e9..7f44da27 100644 --- a/src/renderer/src/hooks/useOperationFee.ts +++ b/src/renderer/src/hooks/useOperationFee.ts @@ -3,7 +3,8 @@ import { API } from '@renderer/api' import { OperationFee, OperationFeeParams } from '@renderer/api/types' import { TransferOperation } from '@renderer/enums/TransferOperation' import { NO_OPERATION_FEE, TRANSITION_FEE_DEBOUNCE_MS, TRANSITION_FEE_ERROR } from '@renderer/constants' -import { useAsyncWithCache } from './useAsyncWithCache' +import { invalidateAsyncCache, useAsyncWithCache } from './useAsyncWithCache' +import { coreSpendSourceKey, platformSpendSourceKey } from '@renderer/utils/coinControl' // Every fee comes from the backend. This only decides when to ask: not before // the destination parses, and not on every keystroke. @@ -11,21 +12,24 @@ export function useOperationFee( walletId: string | null, operation: TransferOperation | null, params: OperationFeeParams, -): OperationFee & { loading: boolean; err: string | null } { - const { destinationValid, amountCredits, recipient, sourceAddress, identityId, noteIndexes } = params +): OperationFee & { loading: boolean; err: string | null; retry: () => void } { + const { destinationValid, amountCredits, amountDuffs, recipient, coreSource, platformSource, identityId, shieldedSource } = params - const noteKey = noteIndexes?.join(',') ?? '' + const noteKey = shieldedSource == null ? '' : `${shieldedSource.kind}:${shieldedSource.noteIndexes.join(',')}` + const platformSourceKey = platformSpendSourceKey(platformSource) + const coreSourceKey = coreSpendSourceKey(coreSource) 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, coreSource, platformSource, identityId, shieldedSource } + return { feeParams, key: `${walletId}:${operation}:${amountCredits}:${amountDuffs}:${recipient}:${coreSourceKey}:${platformSourceKey}:${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. + // 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, recipient, sourceAddress, identityId, noteKey], + [walletId, operation, destinationValid, amountCredits, amountDuffs, recipient, coreSourceKey, platformSourceKey, identityId, noteKey], ) const [settled, setSettled] = useState(null) @@ -49,5 +53,8 @@ export function useOperationFee( const debouncing = pending !== null && pending.key !== settled?.key - return { ...quote.data, loading: quote.loading || debouncing, err: quote.err } + const retry = (): void => { + if (settled) invalidateAsyncCache('operation-fee', settled.key) + } + return { ...quote.data, loading: quote.loading || debouncing, err: quote.err, retry } } diff --git a/src/renderer/src/hooks/useWalletUtxos.ts b/src/renderer/src/hooks/useWalletUtxos.ts new file mode 100644 index 00000000..669d5587 --- /dev/null +++ b/src/renderer/src/hooks/useWalletUtxos.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react' +import { API } from '@renderer/api' +import type { SelectableUtxo } from '@renderer/api/types' +import { useAuth } from '@renderer/contexts/AuthContext' +import { useConnectionModeContext } from '@renderer/contexts/ConnectionModeContext' +import type { WalletUtxosResult } from '@renderer/types/CoinControl' +import { getErrorMessage } from '@renderer/utils/error' + +export function useWalletUtxos(refreshKey = 0): WalletUtxosResult { + const { status } = useAuth() + const walletId = status?.selectedWalletId ?? null + const { syncIncomplete } = useConnectionModeContext() + const [utxos, setUtxos] = useState([]) + const [loading, setLoading] = useState(walletId != null && !syncIncomplete) + const [error, setError] = useState(null) + const [reload, setReload] = useState(0) + + useEffect(() => { + setUtxos([]) + setError(null) + if (!walletId || syncIncomplete) { + setLoading(false) + return + } + let cancelled = false + setLoading(true) + API.getUtxos(walletId) + .then(loaded => { if (!cancelled) setUtxos(loaded) }) + .catch(cause => { + if (!cancelled) setError(`Could not load spendable UTXOs. ${getErrorMessage(cause)}`) + }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [walletId, syncIncomplete, refreshKey, reload]) + + return {utxos, loading, error, retry: () => setReload(current => current + 1)} +} diff --git a/src/renderer/src/types/Amount.ts b/src/renderer/src/types/Amount.ts new file mode 100644 index 00000000..f226e7a7 --- /dev/null +++ b/src/renderer/src/types/Amount.ts @@ -0,0 +1,11 @@ +export interface CreditsAmountProps { + credits: bigint + compact?: boolean + exact?: boolean + unit?: string | null + showFiat?: boolean + align?: 'start' | 'end' | 'center' + amountClassName?: string + unitClassName?: string + className?: string +} diff --git a/src/renderer/src/types/CoinControl.ts b/src/renderer/src/types/CoinControl.ts new file mode 100644 index 00000000..6b5d8943 --- /dev/null +++ b/src/renderer/src/types/CoinControl.ts @@ -0,0 +1,105 @@ +import type { ReactNode } from 'react' +import type { PlatformAddressDto, SelectableUtxo, ShieldedNoteInfo, WalletAddressDto } from '../api/types' +import type { TransferOperation } from '../enums/TransferOperation' + +export type CoinControlSelection = + | { kind: 'automatic' } + | { kind: 'coreAddress'; address: string } + | { kind: 'coreOutpoints'; outpoints: string[] } + | { kind: 'platformAddress'; address: string } + | { kind: 'platformInputs'; inputs: Array<{address: string; credits: bigint}>; feeAddress: string } + | { kind: 'shieldedAddress'; address: string } + | { kind: 'shieldedNotes'; noteIndexes: number[] } + +export interface CoinControlInventory { + coreAddresses: string[] + coreOutpoints: string[] + platformBalances: Record + shieldedAddresses: string[] + shieldedNoteIndexes: number[] +} + +export interface CoinControlFunds { + coreAddresses: WalletAddressDto[] + utxos: SelectableUtxo[] + platformAddresses: PlatformAddressDto[] + shieldedNotes: ShieldedNoteInfo[] +} + +export interface CoinControlTotals { + count: number + duffs: bigint + credits: bigint +} + +export interface CoinControlInputLabel { + singular: string + plural: string +} + +export interface WalletUtxosResult { + utxos: SelectableUtxo[] + loading: boolean + error: string | null + retry: () => void +} + +export interface CoinControlFixedSourceCopy { + title: string + description: string +} + +export interface CoinControlModalProps { + isOpen: boolean + operation: TransferOperation | null + selection: CoinControlSelection + coreAddresses: WalletAddressDto[] + coreAddressesLoading: boolean + coreAddressesError: string | null + onRetryCoreAddresses: () => void + utxos: SelectableUtxo[] + utxosLoading: boolean + utxosError: string | null + coreSyncIncomplete: boolean + platformAddresses: PlatformAddressDto[] + platformAddressesLoading: boolean + platformAddressesError: string | null + onRetryPlatformAddresses: () => void + shieldedNotes: ShieldedNoteInfo[] + identityLabel: string | null + identityId: string | null + platformAddress: PlatformAddressDto | undefined + onRetryUtxos: () => void + onClose: () => void + onApply: (selection: CoinControlSelection) => void +} + +export interface CoinControlEmptyProps { + text: string +} + +export interface CoinControlAmountInputProps { + id: string + credits: bigint + invalid: boolean + onChange: (credits: bigint) => void +} + +export interface CoinControlAddressValueProps { + address: string + detail: ReactNode +} + +export interface CoinControlChoiceRowProps { + checked: boolean + onChange: () => void + children: ReactNode +} + +export interface CoinControlCheckRowProps { + checked: boolean + onChange: (checked: boolean) => void + children: ReactNode + disabled?: boolean + bare?: boolean +} diff --git a/src/renderer/src/types/SendDraft.ts b/src/renderer/src/types/SendDraft.ts index c12dc4d9..9eb631b0 100644 --- a/src/renderer/src/types/SendDraft.ts +++ b/src/renderer/src/types/SendDraft.ts @@ -1,6 +1,6 @@ import { DestinationKind } from '../enums/DestinationKind' import { SourceKind } from '../enums/SourceKind' -import type { SpecificSourcePreferences } from './SpecificSource' +import type { CoinControlSelection } from './CoinControl' export interface SendDraft { fromKind: SourceKind @@ -10,5 +10,5 @@ export interface SendDraft { toValue: string amount: string acked: boolean - specificSourcePreferences: SpecificSourcePreferences + coinControl: CoinControlSelection } diff --git a/src/renderer/src/types/SpecificSource.ts b/src/renderer/src/types/SpecificSource.ts deleted file mode 100644 index 38286556..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.Shielded - -export interface SpecificSourcePreferences { - enabled: boolean - addresses: Record -} 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/balance.ts b/src/renderer/src/utils/balance.ts index d9889ca6..840fd6c9 100644 --- a/src/renderer/src/utils/balance.ts +++ b/src/renderer/src/utils/balance.ts @@ -1,5 +1,22 @@ -const DUFFS_PER_DASH = 100_000_000n -const CREDITS_PER_DUFF = 1_000n +import { CREDITS_PER_DASH, CREDITS_PER_DUFF, DASH_CREDIT_DECIMALS, DUFFS_PER_DASH } from '../constants/balance' + +export function creditsToDash(credits: bigint): string { + const sign = credits < 0n ? '-' : '' + const abs = credits < 0n ? -credits : credits + const whole = abs / CREDITS_PER_DASH + const fraction = abs % CREDITS_PER_DASH + if (fraction === 0n) return `${sign}${whole}` + const digits = fraction.toString().padStart(DASH_CREDIT_DECIMALS, '0').replace(/0+$/, '') + return `${sign}${whole}.${digits}` +} + +export function dashToCredits(value: string): bigint | null { + if (/[^0-9.]/.test(value)) return null + const [whole = '', fraction = '', extra] = value.split('.') + if (extra !== undefined || fraction.length > DASH_CREDIT_DECIMALS) return null + return BigInt(whole || '0') * CREDITS_PER_DASH + + BigInt(fraction.padEnd(DASH_CREDIT_DECIMALS, '0')) +} export function creditsToDuffs(credits: bigint): bigint { const sign = credits < 0n ? -1n : 1n @@ -7,6 +24,12 @@ export function creditsToDuffs(credits: bigint): bigint { return sign * (abs / CREDITS_PER_DUFF) } +export function compareBigIntsDescending(a: bigint, b: bigint): number { + if (a < b) return 1 + if (a > b) return -1 + return 0 +} + export function duffsToCredits(duffs: bigint): bigint { return duffs * CREDITS_PER_DUFF } diff --git a/src/renderer/src/utils/coinControl.ts b/src/renderer/src/utils/coinControl.ts new file mode 100644 index 00000000..8415cfe4 --- /dev/null +++ b/src/renderer/src/utils/coinControl.ts @@ -0,0 +1,254 @@ +import type { + CoreSpendSource, + PlatformSpendSource, + SelectableUtxo, + ShieldedNoteInfo, + ShieldedSpendSource, +} from '../api/types' +import { PLATFORM_INPUT_LIMIT } from '../constants/platform' +import { INPUT_ITEM_LABELS } from '../constants/coinControl' +import { SHIELDED_NOTE_LIMIT } from '../constants/shielded' +import { SourceKind } from '../enums/SourceKind' +import { TransferOperation } from '../enums/TransferOperation' +import type { CoinControlFunds, CoinControlInventory, CoinControlSelection, CoinControlTotals } from '../types/CoinControl' +import { creditsToDuffs, davToDashCompact, duffsToCredits } from './balance' + +export const automaticCoinControl = (): CoinControlSelection => ({kind: 'automatic'}) + +export function buildCoinControlInventory(funds: CoinControlFunds): CoinControlInventory { + const notes = funds.shieldedNotes.filter(note => !note.spent) + return { + coreAddresses: funds.coreAddresses.map(address => address.address), + coreOutpoints: funds.utxos.map(outpointKey), + platformBalances: Object.fromEntries(funds.platformAddresses.map(address => [address.platformAddress, address.balanceCredits])), + shieldedAddresses: [...new Set(notes.map(note => note.address))], + shieldedNoteIndexes: notes.map(note => note.index), + } +} + +export function coinControlSelectionTotals(selection: CoinControlSelection, funds: CoinControlFunds): CoinControlTotals { + let count = 0 + let duffs = 0n + let credits = 0n + switch (selection.kind) { + case 'coreAddress': + count = 1 + duffs = funds.coreAddresses.find(address => address.address === selection.address)?.balance ?? 0n + return {count, duffs, credits: duffsToCredits(duffs)} + case 'coreOutpoints': { + count = selection.outpoints.length + const selected = new Set(selection.outpoints) + duffs = funds.utxos.filter(utxo => selected.has(outpointKey(utxo))).reduce((sum, utxo) => sum + utxo.satoshis, 0n) + return {count, duffs, credits: duffsToCredits(duffs)} + } + case 'platformAddress': + count = 1 + credits = funds.platformAddresses.find(address => address.platformAddress === selection.address)?.balanceCredits ?? 0n + break + case 'platformInputs': + count = selection.inputs.length + credits = selection.inputs.reduce((sum, input) => sum + input.credits, 0n) + break + case 'shieldedAddress': + count = 1 + credits = funds.shieldedNotes.filter(note => !note.spent && note.address === selection.address).reduce((sum, note) => sum + note.amount, 0n) + break + case 'shieldedNotes': { + count = selection.noteIndexes.length + const selected = new Set(selection.noteIndexes) + credits = funds.shieldedNotes.filter(note => !note.spent && selected.has(note.index)).reduce((sum, note) => sum + note.amount, 0n) + break + } + } + return {count, duffs: creditsToDuffs(credits), credits} +} + +export function coinControlSelectionSummary( + selection: CoinControlSelection, + totals: CoinControlTotals, + includeAddressBalance = false, +): string { + let label: string + let addressSelection = false + switch (selection.kind) { + case 'automatic': + return 'Automatic' + case 'coreAddress': + label = 'One Core address' + addressSelection = true + break + case 'platformAddress': + label = 'One Platform address' + addressSelection = true + break + case 'shieldedAddress': + label = 'One shielded address' + addressSelection = true + break + case 'coreOutpoints': + label = `${totals.count} ${coinControlInputLabel(SourceKind.Core, totals.count)}` + break + case 'platformInputs': + label = `${totals.count} ${coinControlInputLabel(SourceKind.PlatformAddress, totals.count)}` + break + case 'shieldedNotes': + label = `${totals.count} ${coinControlInputLabel(SourceKind.Shielded, totals.count)}` + break + } + if (addressSelection && !includeAddressBalance) return label + return `${label} · ${davToDashCompact(totals.duffs)} Dash` +} + +export function coinControlInputLabel(sourceKind: SourceKind | null, count: number): string { + const labels = INPUT_ITEM_LABELS[sourceKind ?? SourceKind.PlatformAddress] + return count === 1 ? labels.singular : labels.plural +} + +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, +): CoinControlSelection { + const sourceKind = coinControlSourceKind(operation) + switch (selection.kind) { + case 'coreAddress': + case 'coreOutpoints': + if (sourceKind === SourceKind.Core) return selection + break + case 'platformAddress': + case 'platformInputs': + if (sourceKind === SourceKind.PlatformAddress) return selection + break + case 'shieldedAddress': + case 'shieldedNotes': + if (sourceKind === SourceKind.Shielded) return selection + break + case 'automatic': + return selection + } + return automaticCoinControl() +} + +export function isCoinControlSelectionValid( + selection: CoinControlSelection, + inventory: CoinControlInventory, +): boolean { + if (selection.kind === 'automatic') return true + + if (selection.kind === 'coreAddress') { + return inventory.coreAddresses.includes(selection.address) + } + if (selection.kind === 'coreOutpoints') { + const available = new Set(inventory.coreOutpoints) + return selection.outpoints.length > 0 + && new Set(selection.outpoints).size === selection.outpoints.length + && selection.outpoints.every(outpoint => available.has(outpoint)) + } + if (selection.kind === 'platformAddress') { + return inventory.platformBalances[selection.address] != null + } + if (selection.kind === 'platformInputs') { + const addresses = selection.inputs.map(input => input.address) + return 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 + }) + } + if (selection.kind === 'shieldedAddress') { + return inventory.shieldedAddresses.includes(selection.address) + } + + const available = new Set(inventory.shieldedNoteIndexes) + return 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)) +} + +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 coreSpendSourceKey(source: CoreSpendSource | null | undefined): string { + if (source == null) return '' + if (source.kind === 'address') return source.address + return source.outpoints.map(outpointKey).join(',') +} + +export function platformSpendSourceKey(source: PlatformSpendSource | null | undefined): string { + if (source == null) return '' + if (source.kind === 'address') return source.address + const inputs = source.inputs.map(input => `${input.address}:${input.credits}`).join(',') + const feeStrategy = source.feeStrategy.map(step => { + if (step.kind === 'deductFromInput') return step.address + return step.index + }).join(',') + return `${inputs}|${feeStrategy}` +} + +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/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/src/renderer/src/utils/sendDraft.ts b/src/renderer/src/utils/sendDraft.ts index 2f6bd3f7..e0ea4cba 100644 --- a/src/renderer/src/utils/sendDraft.ts +++ b/src/renderer/src/utils/sendDraft.ts @@ -1,7 +1,8 @@ import { DestinationKind } from '../enums/DestinationKind' import { SourceKind } from '../enums/SourceKind' import type { SendDraft } from '../types/SendDraft' -import { initialSpecificSourcePreferences } from './specificSource' +import { automaticCoinControl, normalizeCoinControlSelection } from './coinControl' +import { resolveOperation } from './transferMatrix' const sendDrafts = new Map() @@ -22,7 +23,7 @@ export function createSendDraft(from: string | null = null, to: string | null = toValue: '', amount: '', acked: false, - specificSourcePreferences: initialSpecificSourcePreferences(), + coinControl: automaticCoinControl(), } } @@ -38,6 +39,7 @@ export function getOrCreateSendDraft(walletId: string | null, from: string | nul ...(fromKind != null && {fromKind}), ...(toKind != null && {toKind}), } + draft.coinControl = normalizeCoinControlSelection(draft.coinControl, resolveOperation(draft.fromKind, draft.toKind)) sendDrafts.set(walletId, draft) return draft } diff --git a/src/renderer/src/utils/specificSource.ts b/src/renderer/src/utils/specificSource.ts deleted file mode 100644 index 25f04c72..00000000 --- a/src/renderer/src/utils/specificSource.ts +++ /dev/null @@ -1,46 +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.Shielded]: null, - }, - } -} - -export function specificSourceKindForOperation(operation: TransferOperation | null): SpecificSourceKind | null { - if (operation === TransferOperation.CoreSend) return SourceKind.Core - 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/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/api/bulkInserts.test.ts b/tests/api/bulkInserts.test.ts index 8c991b7b..74dfbbfe 100644 --- a/tests/api/bulkInserts.test.ts +++ b/tests/api/bulkInserts.test.ts @@ -94,6 +94,7 @@ describe('bulk inserts past the compound-select limit', () => { amount: 1000n, address: `shielded-${index}`, spent: false, + nullifier: null, }))) expect(await dao.getOwnedNotes(WALLET)).toHaveLength(OVER_LIMIT) @@ -174,6 +175,7 @@ describe('address batches past the bind-variable limit', () => { amount: 1000n, address: `shielded-${index}`, spent: false, + nullifier: null, }))) await dao.markSpent(WALLET, Array.from({length: OVER_BINDINGS}, (_, index) => index)) diff --git a/tests/api/walletUtxos.test.ts b/tests/api/walletUtxos.test.ts new file mode 100644 index 00000000..1034d054 --- /dev/null +++ b/tests/api/walletUtxos.test.ts @@ -0,0 +1,84 @@ +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[], ready = true): WalletProvider => ({ + getWalletUtxos: async () => utxos, + getWalletBalance: async () => 0n, + getBalance: async () => 0n, + ensureReady: async () => { + if (!ready) throw new Error('Wallet sync is not complete') + }, + 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) + }) + + // 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/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/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/balance.test.ts b/tests/unit/balance.test.ts index 985cdee0..2d8ef530 100644 --- a/tests/unit/balance.test.ts +++ b/tests/unit/balance.test.ts @@ -1,8 +1,56 @@ import { describe, it, expect } from 'vitest' -import { creditsToDuffs, davToDash, davToDashCompact, dashToDuffs, duffsToCredits, formatCompactCredits } from '../../src/renderer/src/utils/balance' +import { compareBigIntsDescending, creditsToDash, creditsToDuffs, davToDash, davToDashCompact, dashToCredits, dashToDuffs, duffsToCredits, formatCompactCredits } from '../../src/renderer/src/utils/balance' const ONE_DASH = 100_000_000n +describe('exact Dash credit amounts', () => { + it.each<[bigint, string]>([ + [0n, '0'], + [1n, '0.00000000001'], + [999n, '0.00000000999'], + [1_000n, '0.00000001'], + [100_000_000_000n, '1'], + [125_000_000_000n, '1.25'], + [100_000_000_001n, '1.00000000001'], + [9_007_199_254_740_993n, '90071.99254740993'], + [900719925474099300000000001n, '9007199254740993.00000000001'], + ])('displays and round-trips %s credits without truncation', (credits, dash) => { + expect(creditsToDash(credits)).toBe(dash) + expect(dashToCredits(dash)).toBe(credits) + }) + + it('formats negative balances without allowing negative input caps', () => { + expect(creditsToDash(-1n)).toBe('-0.00000000001') + expect(creditsToDash(-100_000_000_000n)).toBe('-1') + expect(dashToCredits('-0.00000000001')).toBeNull() + }) + + it.each<[string, bigint]>([ + ['', 0n], ['.', 0n], ['0.', 0n], ['0.000', 0n], ['000', 0n], + ['1.', 100_000_000_000n], ['.5', 50_000_000_000n], + ['0.00100', 100_000_000n], ['001.2500', 125_000_000_000n], + ['1.00000000000', 100_000_000_000n], + ])('accepts decimal editing input %j', (value, credits) => { + expect(dashToCredits(value)).toBe(credits) + }) + + it.each([ + '-100', '+100', '1e3', '0x10', '1_000', '12abc', ' 12', '12 ', '12\n', '١٢', + '1.2.3', '..', '1,5', '0.000000000001', '1.000000000000', + ])('rejects malformed or overprecise Dash input %j without rounding', value => { + expect(dashToCredits(value)).toBeNull() + }) +}) + +describe('compareBigIntsDescending', () => { + it('orders close values beyond Number precision without disturbing equal values', () => { + expect([9_007_199_254_740_992n, 0n, 9_007_199_254_740_993n].sort(compareBigIntsDescending)).toEqual([ + 9_007_199_254_740_993n, 9_007_199_254_740_992n, 0n, + ]) + expect(compareBigIntsDescending(9_007_199_254_740_993n, 9_007_199_254_740_993n)).toBe(0) + }) +}) + describe('creditsToDuffs', () => { it('converts at 1000 credits per duff', () => { expect(creditsToDuffs(1_000n)).toBe(1n) diff --git a/tests/unit/coinControl.test.ts b/tests/unit/coinControl.test.ts new file mode 100644 index 00000000..11292efe --- /dev/null +++ b/tests/unit/coinControl.test.ts @@ -0,0 +1,253 @@ +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 { CoinControlFunds, CoinControlInventory, CoinControlSelection } from '../../src/renderer/src/types/CoinControl' +import { + automaticCoinControl, + buildCoinControlInventory, + coinControlSelectionSummary, + coinControlSelectionTotals, + coinControlSourceKind, + coreSpendSourceKey, + isCoinControlSelectionValid, + normalizeCoinControlSelection, + platformSpendSourceKey, + 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], +} + +const funds: CoinControlFunds = { + coreAddresses: [{ + walletId: 'wallet-a', accountId: 0, address: 'core-a', derivationPath: '', index: 0, + isChange: 0, isUsed: true, balance: 100_000_000n, txCount: 1, label: null, usdBalance: null, + }], + utxos: [ + {txid: 'tx-a', vout: 0, satoshis: 1n, address: 'core-a', height: 1}, + {txid: 'tx-b', vout: 1, satoshis: 99_999_999n, address: 'core-a', height: 1}, + ], + platformAddresses: [ + {platformAddress: 'platform-a', balanceCredits: 5_000_000n, nonce: 0}, + {platformAddress: 'platform-b', balanceCredits: 7_000_000n, nonce: 0}, + ], + shieldedNotes: [ + {index: 4, address: 'shielded-a', amount: 1_000n, spent: false}, + {index: 8, address: 'shielded-a', amount: 1_999n, spent: false}, + {index: 99, address: 'shielded-spent', amount: 1_000_000n, spent: true}, + ], +} + +describe('coin control', () => { + it('preserves Core fee-cache keys for automatic, address and ordered outpoint sources', () => { + expect(coreSpendSourceKey(null)).toBe('') + expect(coreSpendSourceKey(undefined)).toBe('') + expect(coreSpendSourceKey({kind: 'address', address: 'core-a'})).toBe('core-a') + expect(coreSpendSourceKey({kind: 'outpoints', outpoints: [{txid: 'tx-b', vout: 1}, {txid: 'tx-a', vout: 0}]})).toBe('tx-b:1,tx-a:0') + }) + + it('preserves Platform fee-cache keys including exact caps and ordered fee strategies', () => { + expect(platformSpendSourceKey(null)).toBe('') + expect(platformSpendSourceKey(undefined)).toBe('') + expect(platformSpendSourceKey({kind: 'address', address: 'platform-a'})).toBe('platform-a') + expect(platformSpendSourceKey({ + kind: 'inputs', + inputs: [{address: 'platform-b', credits: 9_007_199_254_740_993n}, {address: 'platform-a', credits: 1n}], + feeStrategy: [{kind: 'reduceOutput', index: 0}, {kind: 'deductFromInput', address: 'platform-b'}], + })).toBe('platform-b:9007199254740993,platform-a:1|0,platform-b') + }) + + it('builds validation inventory without spent notes and duplicate shielded addresses', () => { + expect(buildCoinControlInventory(funds)).toEqual(inventory) + }) + + it('counts missing selections but never substitutes unselected Core funds into their total', () => { + expect(coinControlSelectionTotals({kind: 'coreOutpoints', outpoints: ['tx-a:0', 'missing:1']}, funds)).toEqual({ + count: 2, duffs: 1n, credits: 1_000n, + }) + expect(coinControlSelectionTotals({kind: 'coreAddress', address: 'core-a'}, funds)).toEqual({ + count: 1, duffs: 100_000_000n, credits: 100_000_000_000n, + }) + expect(coinControlSelectionTotals({kind: 'coreAddress', address: 'missing'}, funds).duffs).toBe(0n) + }) + + it('totals Platform input caps exactly without replacing caps with address balances', () => { + expect(coinControlSelectionTotals({ + kind: 'platformInputs', + inputs: [{address: 'platform-a', credits: 9_007_199_254_740_993n}, {address: 'platform-b', credits: 9n}], + feeAddress: 'platform-b', + }, funds)).toEqual({count: 2, credits: 9_007_199_254_741_002n, duffs: 9_007_199_254_741n}) + expect(coinControlSelectionTotals({kind: 'platformAddress', address: 'platform-a'}, funds).credits).toBe(5_000_000n) + }) + + it('retains sub-duff credits and excludes spent shielded notes from totals', () => { + expect(coinControlSelectionTotals({kind: 'shieldedAddress', address: 'shielded-a'}, funds)).toEqual({ + count: 1, credits: 2_999n, duffs: 2n, + }) + expect(coinControlSelectionTotals({kind: 'shieldedNotes', noteIndexes: [8, 99]}, funds)).toEqual({ + count: 2, credits: 1_999n, duffs: 1n, + }) + }) + + it('formats address summaries with the optional registration balance and input counts consistently', () => { + const address: CoinControlSelection = {kind: 'coreAddress', address: 'core-a'} + const totals = coinControlSelectionTotals(address, funds) + expect(coinControlSelectionSummary(address, totals)).toBe('One Core address') + expect(coinControlSelectionSummary(address, totals, true)).toBe('One Core address · 1 Dash') + const inputs: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0', 'tx-b:1']} + expect(coinControlSelectionSummary(inputs, coinControlSelectionTotals(inputs, funds))).toBe('2 UTXOs · 1 Dash') + const automatic = automaticCoinControl() + expect(coinControlSelectionSummary(automatic, coinControlSelectionTotals(automatic, funds))).toBe('Automatic') + }) + + 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 only an incompatible route to automatic', () => { + const core: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0']} + expect(normalizeCoinControlSelection(core, TransferOperation.AddressFundsTransfer)).toEqual(automaticCoinControl()) + expect(normalizeCoinControlSelection(core, TransferOperation.AssetLockFunding)).toBe(core) + expect(normalizeCoinControlSelection(core, null)).toEqual(automaticCoinControl()) + }) + + it('preserves manual UTXOs while inventory is empty and validates them again after reload', () => { + const selection: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0']} + const applied = normalizeCoinControlSelection(selection, TransferOperation.CoreSend) + expect(applied).toBe(selection) + expect(isCoinControlSelectionValid(applied, {...inventory, coreOutpoints: []})).toBe(false) + expect(isCoinControlSelectionValid(applied, inventory)).toBe(true) + expect(toCoreSpendSource(applied, [])).toEqual({kind: 'outpoints', outpoints: []}) + }) + + it('preserves Platform caps when a balance decreases rather than authorizing automatic funding', () => { + const selection: CoinControlSelection = { + kind: 'platformInputs', + inputs: [{address: 'platform-a', credits: 5_000_000n}], + feeAddress: 'platform-a', + } + const applied = normalizeCoinControlSelection(selection, TransferOperation.AddressFundsTransfer) + expect(applied).toBe(selection) + expect(isCoinControlSelectionValid(applied, inventory)).toBe(true) + expect(isCoinControlSelectionValid(applied, { + ...inventory, + platformBalances: {'platform-a': 4_999_999n}, + })).toBe(false) + expect(toPlatformSpendSource(applied)?.kind).toBe('inputs') + }) + + it.each([ + {kind: 'coreAddress', address: 'missing'}, + {kind: 'coreOutpoints', outpoints: ['tx-a:0', 'missing:1']}, + {kind: 'platformAddress', address: 'missing'}, + {kind: 'shieldedAddress', address: 'missing'}, + {kind: 'shieldedNotes', noteIndexes: [4, 99]}, + {kind: 'coreOutpoints', outpoints: ['tx-a:0', 'tx-a:0']}, + {kind: 'shieldedNotes', noteIndexes: [4, 4]}, + {kind: 'platformInputs', inputs: [{address: 'platform-a', credits: 1n}], feeAddress: 'platform-b'}, + ])('invalidates unavailable, duplicate, or incomplete manual selection $kind', selection => { + expect(isCoinControlSelectionValid(selection, inventory)).toBe(false) + }) + + 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(isCoinControlSelectionValid({ + kind: 'platformInputs', + inputs: platformInputs, + feeAddress: platformInputs[0].address, + }, platformInventory)).toBe(false) + + expect(isCoinControlSelectionValid({ + kind: 'platformInputs', + inputs: [{address: 'platform-a', credits: 5_000_001n}], + feeAddress: 'platform-a', + }, inventory)).toBe(false) + + expect(isCoinControlSelectionValid({ + kind: 'shieldedNotes', + noteIndexes: Array.from({length: SHIELDED_NOTE_LIMIT + 1}, (_, index) => index), + }, { + ...inventory, + shieldedNoteIndexes: Array.from({length: SHIELDED_NOTE_LIMIT + 1}, (_, index) => index), + })).toBe(false) + }) +}) diff --git a/tests/unit/coinSelection.test.ts b/tests/unit/coinSelection.test.ts index 61ddcf11..ea0102f1 100644 --- a/tests/unit/coinSelection.test.ts +++ b/tests/unit/coinSelection.test.ts @@ -1,83 +1,170 @@ import { describe, it, expect } from 'vitest' -import {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 {maxSelectableAmount, selectCoins} from '../../src/main/src/utils/coinSelection' +import {CoreSpendSource, SelectableUtxo} from '../../src/main/src/types/CoinSelection' +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 { - 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, 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) + }) +}) + +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/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..93ff8684 100644 --- a/tests/unit/estimateFee.test.ts +++ b/tests/unit/estimateFee.test.ts @@ -7,8 +7,14 @@ 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 {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 {ASSET_LOCK_PAYLOAD_BYTES} from '../../src/main/src/constants/chain' +import {coreFeeDuffsFor} from '../../src/main/src/utils/coreFeeRate' import { DEFAULT_CORE_FEE_MULTIPLIER, DEFAULT_PLATFORM_FEE_MULTIPLIER, @@ -18,6 +24,22 @@ 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) +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) + +function utxo(satoshis: bigint, index: number): UTXO { + 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) @@ -25,7 +47,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 +55,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(), ) @@ -51,7 +80,7 @@ function service(candidates: PlatformSourceCandidate[] = []): { } 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', platformSource: null, identityId: IDENTITY, shieldedSource: null, ...overrides} } function feeCalls(request: ReturnType): Array<{operation: string; params: FeeQuoteParams}> { @@ -120,9 +149,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() } }) @@ -133,32 +162,205 @@ 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]) - expect(fee).toEqual({feeCredits: 7n, feeDuffs: null, maxPerTx: 90n, noteLimit: 6}) + 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, 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() - 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)) + }) + + // 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)) + }) + + // 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 () => { + const {service: svc} = service([], [utxo(ONE_DASH, 1)]) + + await expect(svc.estimateFee(WALLET, 'identityRegister', params({ + amountDuffs: 1_000n, + 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 + // 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 () => { 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: ASSET_LOCK_FEE(1), + maxDuffs: ONE_DASH - ASSET_LOCK_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/platformTransfer.test.ts b/tests/unit/platformTransfer.test.ts index 80a55b0c..76ec8586 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,328 @@ 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, 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(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)])).error) + .toMatch(/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)])).error) + .toMatch(/not found in this wallet/) + }) + + 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([]) + }) + + // 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)])).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('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([]) + }) +}) + +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..4c9ed4f9 100644 --- a/tests/unit/sendDraft.test.ts +++ b/tests/unit/sendDraft.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' import { DestinationKind } from '../../src/renderer/src/enums/DestinationKind' import { SourceKind } from '../../src/renderer/src/enums/SourceKind' +import type { CoinControlSelection } from '../../src/renderer/src/types/CoinControl' +import { isCoinControlSelectionValid } from '../../src/renderer/src/utils/coinControl' import { clearSendDraft, createSendDraft, @@ -22,6 +24,7 @@ describe('send drafts', () => { expect(createSendDraft('invalid', 'invalid')).toMatchObject({ fromKind: SourceKind.Core, toKind: DestinationKind.CoreAddress, + coinControl: {kind: 'automatic'}, }) }) @@ -33,13 +36,6 @@ describe('send drafts', () => { toValue: 'recipient', amount: '1.25', acked: true, - specificSourcePreferences: { - enabled: true, - addresses: { - [SourceKind.Core]: 'core-source', - [SourceKind.Shielded]: 'shielded-source', - }, - }, } saveSendDraft('wallet-a', draft) @@ -65,13 +61,18 @@ describe('send drafts', () => { }) it('keeps drafts isolated by wallet and removes a cleared draft', () => { - const walletA = { ...createSendDraft(), toValue: 'wallet-a-recipient' } + const walletA = { + ...createSendDraft(), + toValue: 'wallet-a-recipient', + coinControl: {kind: 'coreAddress', address: 'core-a'} as CoinControlSelection, + } saveSendDraft('wallet-a', walletA) expect(getOrCreateSendDraft('wallet-b', SourceKind.Shielded, DestinationKind.Shielded)).toMatchObject({ fromKind: SourceKind.Shielded, toKind: DestinationKind.Shielded, toValue: '', + coinControl: {kind: 'automatic'}, }) clearSendDraft('wallet-a') @@ -79,6 +80,55 @@ describe('send drafts', () => { fromKind: SourceKind.Core, toKind: DestinationKind.PlatformAddress, toValue: '', + coinControl: {kind: 'automatic'}, + }) + }) + + it.each<{from: SourceKind; selection: CoinControlSelection}>([ + {from: SourceKind.Core, selection: {kind: 'coreAddress', address: 'core-a'}}, + {from: SourceKind.Core, selection: {kind: 'coreOutpoints', outpoints: ['tx-a:0', 'tx-b:1']}}, + {from: SourceKind.PlatformAddress, selection: {kind: 'platformAddress', address: 'platform-a'}}, + { + from: SourceKind.PlatformAddress, + selection: { + kind: 'platformInputs', + inputs: [{address: 'platform-a', credits: 9_007_199_254_740_993n}, {address: 'platform-b', credits: 3n}], + feeAddress: 'platform-b', + }, + }, + {from: SourceKind.Shielded, selection: {kind: 'shieldedAddress', address: 'shielded-a'}}, + {from: SourceKind.Shielded, selection: {kind: 'shieldedNotes', noteIndexes: [4, 8]}}, + ])('restores $selection.kind together with the recipient and amount', ({from, selection}) => { + const draft = { + ...createSendDraft(from, DestinationKind.CoreAddress), + toValue: 'recipient', + amount: '1.25', + coinControl: selection, + } + saveSendDraft('wallet-a', draft) + + expect(getOrCreateSendDraft('wallet-a', null, null)).toEqual(draft) + }) + + it('retains a restored selection until its inventory can be validated', () => { + const selection: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0']} + saveSendDraft('wallet-a', {...createSendDraft(), coinControl: selection}) + + const restored = getOrCreateSendDraft('wallet-a', null, null) + expect(isCoinControlSelectionValid(restored.coinControl, { + coreAddresses: [], coreOutpoints: [], platformBalances: {}, shieldedAddresses: [], shieldedNoteIndexes: [], + })).toBe(false) + expect(getOrCreateSendDraft('wallet-a', null, null).coinControl).toEqual(selection) + }) + + it('preserves compatible URL route changes and clears an incompatible selection', () => { + const selection: CoinControlSelection = {kind: 'coreOutpoints', outpoints: ['tx-a:0']} + saveSendDraft('wallet-a', {...createSendDraft(), coinControl: selection, amount: '1.25'}) + + expect(getOrCreateSendDraft('wallet-a', null, DestinationKind.PlatformAddress).coinControl).toEqual(selection) + expect(getOrCreateSendDraft('wallet-a', SourceKind.PlatformAddress, null)).toMatchObject({ + amount: '1.25', + coinControl: {kind: 'automatic'}, }) }) }) diff --git a/tests/unit/shieldedBuildTransition.test.ts b/tests/unit/shieldedBuildTransition.test.ts new file mode 100644 index 00000000..2bf9007d --- /dev/null +++ b/tests/unit/shieldedBuildTransition.test.ts @@ -0,0 +1,176 @@ +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} { + 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', + 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', () => { + // 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: [ + {address: 'addr-a', amountCredits: 1_000n}, + {address: 'addr-b', amountCredits: 2_500n}, + ], + amountCredits: 3_500n, + }), [], ANCHOR, CHANGE) + + 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, createStateTransition} = 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) + + expect(outputsOf(createStateTransition).map(output => output.amount)).toEqual([9n, 1n, 90n]) + }) + + 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('shieldedTransfer-transition') + expect(createStateTransition).toHaveBeenCalledWith('shieldedTransfer', expect.objectContaining({ + transferAmount: 1_000n, + memo: expect.anything(), + })) + }) + + // 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`)) + }) +}) diff --git a/tests/unit/shieldedNoteSelection.test.ts b/tests/unit/shieldedNoteSelection.test.ts index fced31dd..7b198916 100644 --- a/tests/unit/shieldedNoteSelection.test.ts +++ b/tests/unit/shieldedNoteSelection.test.ts @@ -1,12 +1,26 @@ import { describe, it, expect } from 'vitest' -import {maxSpendableCredits, selectSpendNotes} from '../../src/main/src/utils/shieldedNoteSelection' -import {SelectableNote} from '../../src/main/src/types/ShieldedNoteSelection' +import { + bundleActions, + maxSpendableCredits, + requireShieldedRecipients, + picksMissingNotes, + 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 } } 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 +103,207 @@ 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]) + }) + + // 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)).map(n => n.index)).toEqual([0]) + expect(picksMissingNotes(selectableNotes(notes, picked(0, 1)), picked(0, 1))).toBe(true) + }) + + 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) + }) +}) + +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) + }) +}) + +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) 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/specificSource.test.ts b/tests/unit/specificSource.test.ts deleted file mode 100644 index 0a4c177e..00000000 --- a/tests/unit/specificSource.test.ts +++ /dev/null @@ -1,47 +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 Core and Shielded addresses independent when the shared setting is toggled', () => { - const withCore = updateSpecificSourceAddress(initialSpecificSourcePreferences(), SourceKind.Core, 'core-address') - const withShielded = updateSpecificSourceAddress(withCore, 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.Shielded]: 'shielded-address', - }, - }) - }) - - it.each([ - [TransferOperation.CoreSend, SourceKind.Core], - [TransferOperation.ShieldedTransfer, SourceKind.Shielded], - [TransferOperation.Unshield, SourceKind.Shielded], - [TransferOperation.ShieldedWithdrawal, SourceKind.Shielded], - [TransferOperation.IdentityCreateFromShielded, null], - [TransferOperation.AssetLockFunding, null], - ])('maps %s to its applicable preference', (operation, expected) => { - expect(specificSourceKindForOperation(operation)).toBe(expected) - }) -}) diff --git a/tests/unit/transferInputs.test.ts b/tests/unit/transferInputs.test.ts index 7a95f706..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 {CORE_TRANSFER_FEE_DUFFS} from '../../src/main/src/constants/chain' +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. @@ -26,7 +28,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}) @@ -35,7 +37,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. @@ -55,18 +57,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) @@ -112,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) + }) +}) diff --git a/tests/unit/transitionFee.test.ts b/tests/unit/transitionFee.test.ts index f80e0e77..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,9 +85,9 @@ function params(overrides: Partial = {}): FeeQuoteParams { return { amountCredits: 1_000_000n, recipient: IDENTITY, - sourceAddress: null, + platformSource: null, identityId: IDENTITY, - noteIndexes: null, + shieldedSource: null, inputCount: 1, coreFeePerByte: 1, ...overrides, @@ -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) diff --git a/yarn.lock b/yarn.lock index 806d88be..5ce82d89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2996,10 +2996,10 @@ dash-core-sdk@1.1.3-dev.6: 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"