Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
40db8e0
implement method coreFeeDuffsFor and use it
owl352 Aug 29, 2026
698e113
fix asset lock size calculation
owl352 Aug 29, 2026
6b6bba5
optimize calculations
owl352 Aug 29, 2026
4f0585a
fix import
owl352 Aug 29, 2026
8814964
implement coin control for l1 transfers and utxo ipc endpoint
owl352 Aug 29, 2026
06991d5
implement coin control for l1 asset lock funding
owl352 Aug 29, 2026
73e24df
implement shielded coin control
owl352 Aug 30, 2026
00388b8
platform addresses coin control + ui demo in/out picker
owl352 Aug 31, 2026
ed02821
implement output coin control for l1 + pickers placeholders
owl352 Aug 31, 2026
962186b
implement output coin control for shielded + pickers placeholders
owl352 Sep 1, 2026
fb4711f
tiny fixes for shielded coin control
owl352 Sep 1, 2026
ed121b6
bump sdk version and simplify shielded st creation
owl352 Sep 1, 2026
a660d80
remove logs for platform transfers
owl352 Sep 1, 2026
a5333f0
tiny fixes
owl352 Sep 1, 2026
e2105b2
Merge branch 'master' into feat/coin-control
owl352 Sep 1, 2026
7cb23a6
Merge branch 'master' into feat/coin-control
owl352 Sep 5, 2026
ca6a72f
remove unused import
owl352 Sep 5, 2026
df095b8
Merge branch 'master' into feat/coin-control
owl352 Sep 7, 2026
7522485
feat: coin control UI wip
r0man1337 Sep 3, 2026
939b6bb
fix: refine coin control selection UI
r0man1337 Sep 4, 2026
18a5afa
fix: refresh identities in send source picker
r0man1337 Sep 4, 2026
a31cbcc
fix: hide coin control for identity sources
r0man1337 Sep 4, 2026
35a4120
feat: add coin control to identity registration
r0man1337 Sep 7, 2026
8e555d1
fix: preserve invalid manual coin selections
r0man1337 Sep 9, 2026
13cc73c
fix: persist coin control in send drafts
r0man1337 Sep 9, 2026
56d1000
fix: reset dust filter when reopening coin control
r0man1337 Sep 9, 2026
8182975
fix: show coin control loading errors in toasts
r0man1337 Sep 9, 2026
37506a8
fix: reject malformed coin control credit limits
r0man1337 Sep 9, 2026
58e75dd
refactor: centralize coin control types and constants
r0man1337 Sep 9, 2026
4554454
refactor: share coin control loading and selection logic
r0man1337 Sep 9, 2026
2a55721
refactor: replace nested transfer conditionals
r0man1337 Sep 9, 2026
3a90a6e
fix: show full coin control hashes and addresses
r0man1337 Sep 9, 2026
27a4dcf
fix: use exact Dash amounts in coin control
r0man1337 Sep 9, 2026
debdfd4
fix: accept optional sources in fee cache keys
r0man1337 Sep 9, 2026
1160260
Merge pull request #168 from pshenmic/feat/coin-control-ui
pshenmic Sep 9, 2026
9f7931a
Merge branch 'master' into feat/coin-control
owl352 Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/main/migrations/0019_shielded_note_nullifier.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await knex.schema.alterTable('shielded_notes', table => {
table.binary('nullifier').nullable()
})
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('shielded_notes', table => {
table.dropColumn('nullifier')
})
}
2 changes: 2 additions & 0 deletions src/main/platform/PlatformService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/main/platform/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down Expand Up @@ -79,6 +83,7 @@ export function laneFor(request: PlatformRequestMessage): string | null {
case 'poolInfo':
case 'notesCount':
case 'encryptedNotes':
case 'checkNullifiers':
return null
}
}
4 changes: 2 additions & 2 deletions src/main/platform/operations/address/createIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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,
})
Expand Down
19 changes: 12 additions & 7 deletions src/main/platform/operations/address/infos.ts
Original file line number Diff line number Diff line change
@@ -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<Result> {
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}
}
11 changes: 10 additions & 1 deletion src/main/platform/operations/address/signInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/main/platform/operations/address/topUpIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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,
})
Expand Down
18 changes: 10 additions & 8 deletions src/main/platform/operations/address/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result> {
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)}
}
4 changes: 2 additions & 2 deletions src/main/platform/operations/address/withdrawal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand All @@ -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,
Expand Down
18 changes: 12 additions & 6 deletions src/main/platform/operations/fee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}
}

Expand All @@ -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':
Expand Down
3 changes: 2 additions & 1 deletion src/main/platform/operations/shielded/checkSpent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -14,7 +15,7 @@ export async function checkSpent(
): Promise<CheckedNote[]> {
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}))
Expand Down
8 changes: 0 additions & 8 deletions src/main/platform/operations/shielded/constants.ts
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions src/main/platform/operations/shielded/reads/checkNullifiers.ts
Original file line number Diff line number Diff line change
@@ -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<Result> {
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)}
}
17 changes: 17 additions & 0 deletions src/main/platform/operations/shielded/reads/nullifierStatuses.ts
Original file line number Diff line number Diff line change
@@ -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<ShieldedNullifierStatus[]> {
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
}
Loading
Loading