Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions docs/protocol/decision-proposals.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,12 @@ it means is up to the people who read it.

## From the SDKs

The Rust SDK (`dash_sdk::platform::decision_proposals`) and the JavaScript SDKs
The Rust SDK (`dash_sdk::platform::decision_proposals`), the JavaScript SDKs
(`sdk.decisionProposals` in `@dashevo/evo-sdk`, the `*DecisionProposal*`
methods of `@dashevo/wasm-sdk`) build a proposal to create as a document, give
methods of `@dashevo/wasm-sdk`) and the mobile SDKs (the
`dash_sdk_decision_proposal_*` C functions of `rs-sdk-ffi`, wrapped by the
Swift SDK and by the Kotlin SDK's `Sdk.decisionProposals` and
`PlatformWalletManager.decisionProposals`) build a proposal to create as a document, give
an existing one new text to replace it with, list proposals through the three
indexes a page at a time, and read and answer a proposal's poll by the
proposal's id. A masternode's vote names the poll by its id, which the SDKs
Expand Down
19 changes: 13 additions & 6 deletions packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,19 @@ Most Platform actions have hard preconditions. Establish these fixtures before s

| ID | Action | Layer | Tier | Status | Tags | Entry point & test notes |
|---|---|---|---|---|---|---|
| VOTE-01 | Vote on contested DPNS username (masternode vote) | Platform | Thorough | βœ… | contested, masternode | `ContestDetailScreen` cast-vote β†’ `dash_sdk_contested_resource_cast_vote`. **Requires masternode voting credentials** β€” environment-limited otherwise. |
| VOTE-01 | Vote on contested DPNS username (masternode vote) | Platform | Thorough | βœ… | contested, masternode | `ContestDetailScreen` cast-vote β†’ `dash_sdk_contested_resource_cast_vote`. The voting key (`contestDetail.votingKey`) shows as dots and a screenshot of the open dialog comes out blank (FLAG_SECURE). **Requires masternode voting credentials** β€” environment-limited otherwise. |
| VOTE-02 | Query contested resources | Platform | Thorough | βœ… | contested, read-only | `QueriesListScreen` (getContestedResources). |
| VOTE-03 | Query contested-resource vote state | Platform | Thorough | βœ… | contested, read-only | `QueriesListScreen` (getContestedResourceVoteState). |
| VOTE-04 | Query voters for a contestant identity | Platform | Thorough | βœ… | contested, read-only | `QueriesListScreen` (getContestedResourceVotersForIdentity). |
| VOTE-05 | Query an identity's votes | Platform | Thorough | βœ… | contested, read-only | `QueriesListScreen` (getContestedResourceIdentityVotes). |
| VOTE-06 | Query vote polls by end date | Platform | Thorough | βœ… | contested, read-only | `QueriesListScreen` (getVotePollsByEndDate). |
| VOTE-07 | Masternode vote (generic builder entry) | Platform | β€” | βž– | | Retired β€” covered by `VOTE-01`. |
| VOTE-08 | List decision proposals (open / mine / newest, paged) | Platform | Thorough | βœ… | proposal, read-only | `QueriesListScreen` β†’ Decision Proposals (`votingQueries.decisionProposalsLink`) β†’ `DecisionProposalsScreen`: listing picker (`decisionProposals.listing`), identity picker for Mine (`decisionProposals.ownerPicker`), Load More (`decisionProposals.loadMore`). Needs protocol version 15; an earlier network shows a load error. |
| VOTE-09 | Create a decision proposal | Platform | Thorough | βœ… | proposal | `DecisionProposalsScreen` + (`decisionProposals.create`) β†’ `CreateDecisionProposalScreen` (`createDecisionProposal.*`) β†’ `PlatformWalletManager.decisionProposals.create` (one `platform_wallet_decision_proposal_create` call; the success section shows the returned id). Pays the decision proposal fund (0.5 Dash) plus fees. While it broadcasts, system Back does nothing, and a rotation or a bottom-tab switch does not cancel it: the screen shows the outcome when it composes again. Platform refuses a pass percent outside 50 to 80, a turnout above 50 and an end date outside one to four weeks. Protocol version 15. |
| VOTE-10 | View a decision proposal and its poll state | Platform | Thorough | βœ… | proposal, read-only | `DecisionProposalDetailScreen` (row `decisionProposals.row.<id>`): rule, poll status (`decisionProposal.pollStatus`: open with tallies, or finished with its result), poll id. |
| VOTE-11 | Edit a decision proposal's text | Platform | Thorough | βœ… | proposal | Detail β†’ Edit Text (`decisionProposal.edit`, shown only when a local wallet identity filed it) β†’ `editDecisionProposal.*` β†’ `PlatformWalletManager.decisionProposals.edit` (one `platform_wallet_decision_proposal_edit` call; no signing key is chosen in the app). Accepted for a day after creation; an edit that changes nothing is refused before anything is broadcast, and an edit that raced another one is refused by Platform (consensus code 40106) rather than overwriting it. The dialog cannot be dismissed while it broadcasts. |
| VOTE-12 | Masternode vote on a decision proposal (yes / no / abstain) | Platform | Thorough | βœ… | proposal, masternode | Detail β†’ Vote (`decisionProposal.vote`) β†’ `decisionProposalVote.choice` / `.proTxHash` / `.votingKey` / `.submit` β†’ `dash_sdk_decision_proposal_cast_vote`. The voting key shows as dots and a screenshot of the open dialog comes out blank (FLAG_SECURE). **Requires masternode voting credentials**; a vote from a regular wallet is refused (no voter identity). |
| VOTE-13 | Read a masternode's answer on a decision proposal | Platform | Thorough | βœ… | proposal, masternode, read-only | Vote dialog β†’ Read This Masternode's Answer (`decisionProposalVote.lookUp`) β†’ `dash_sdk_decision_proposal_get_masternode_vote`. No answer once the poll finished. |

### 4.6 Data Contracts β€” `Domain=Contract`

Expand Down Expand Up @@ -306,7 +312,7 @@ Counts are of rows reachable in the app (Status `βœ…`/`πŸ§ͺ`/`⚠️`); `πŸ”Œ`/`
|---|---|---|
| Essential | 21 | yes |
| Common | 29 | yes |
| Thorough | 35 | yes |
| Thorough | 41 | yes |
| Uncommon | 25 | yes |
| Manual | 1 (`CORE-08`) | no β€” physical device |

Expand All @@ -315,7 +321,7 @@ Counts are of rows reachable in the app (Status `βœ…`/`πŸ§ͺ`/`⚠️`); `πŸ”Œ`/`
| Layer | Count (approx.) |
|---|---|
| Core | 17 |
| Platform | ~74 |
| Platform | ~80 |
| Cross | 7 |
| Shielded | 16 |

Expand All @@ -329,7 +335,7 @@ Membership of each feature category across **all** sections (primary section mem
- **Identity** β€” `ID-01..15`, `SH-11`
- **Address** (DIP-17 platform addresses) β€” `ADDR-01..04`, `ADDR-06..09`, `ID-06`, `ID-08`, `ID-11`
- **DPNS** β€” `DPNS-01..08`
- **Voting** β€” `VOTE-01..07`, `DPNS-05`, `DPNS-08`
- **Voting** β€” `VOTE-01..13`, `DPNS-05`, `DPNS-08`
- **Contract** β€” `DC-01..04`
- **Document** β€” `DOC-01..15`
- **Token** β€” `TOK-01..20`
Expand All @@ -346,5 +352,6 @@ Tags are cross-cutting modalities. A test may appear under multiple tags.
- **contested** β€” `DPNS-05`, `DPNS-08`, `VOTE-01..06`
- **withdrawal** β€” `ID-10`, `ADDR-04`, `SH-08`, `SH-16`
- **regression** β€” `TOK-17`, `DP-10`
- **read-only** β€” `SH-13`, `SYS-05`, `SYS-06`, `VOTE-02..06`, `TOK-18`, `TOK-19`
- **masternode** β€” `VOTE-01`
- **read-only** β€” `SH-13`, `SYS-05`, `SYS-06`, `VOTE-02..06`, `VOTE-08`, `VOTE-10`, `VOTE-13`, `TOK-18`, `TOK-19`
- **masternode** β€” `VOTE-01`, `VOTE-12`, `VOTE-13`
- **proposal** (decision proposals, protocol version 15): `VOTE-08..13`
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import org.dashfoundation.example.ui.diagnostics.KeystoreExplorerScreen
import org.dashfoundation.example.ui.diagnostics.WalletMemoryExplorerScreen
import org.dashfoundation.example.ui.identity.AddIdentityKeyScreen
import org.dashfoundation.example.ui.identity.ContestDetailScreen
import org.dashfoundation.example.ui.identity.CreateDecisionProposalScreen
import org.dashfoundation.example.ui.identity.CreateIdentityScreen
import org.dashfoundation.example.ui.identity.DecisionProposalDetailScreen
import org.dashfoundation.example.ui.identity.DecisionProposalsScreen
import org.dashfoundation.example.ui.dashpay.AddContactScreen
import org.dashfoundation.example.ui.dashpay.ContactDetailScreen
import org.dashfoundation.example.ui.dashpay.ContactRequestsScreen
Expand Down Expand Up @@ -472,6 +475,17 @@ fun AppNavHost(
ContestDetailScreen(route.contestName, route.identityIdHex, navController)
}

composable<DecisionProposalsList> { DecisionProposalsScreen(navController) }

composable<CreateDecisionProposal> { CreateDecisionProposalScreen(navController) }

composable<DecisionProposalDetail> { entry ->
DecisionProposalDetailScreen(
entry.toRoute<DecisionProposalDetail>().proposalId,
navController,
)
}

composable<GroupDetail> { entry ->
val route = entry.toRoute<GroupDetail>()
GroupDetailScreen(route.contractIdHex, route.position, navController)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,22 @@ import kotlinx.serialization.Serializable
val identityIdHex: String = "",
)

/**
* Decision proposal listings: the open ones, a local identity's, the newest
* (← `DecisionProposalsView.swift`). Reached from the Platform Queries list.
*/
@Serializable object DecisionProposalsList

/** File a new decision proposal (← `CreateDecisionProposalView`). */
@Serializable object CreateDecisionProposal

/**
* One decision proposal: its rule, its poll's state, the edit and the
* masternode vote (← `DecisionProposalDetailView.swift`). [proposalId] is
* base58.
*/
@Serializable data class DecisionProposalDetail(val proposalId: String)

/**
* One contract group's members + live proposals (← `GroupDetailView.swift`).
* [position] is the group contract position inside the contract's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import org.dashfoundation.example.di.LocalAppState
import org.dashfoundation.example.navigation.AddressQueries
import org.dashfoundation.example.navigation.DecisionProposalsList
import org.dashfoundation.example.navigation.Diagnostics
import org.dashfoundation.example.navigation.GroveDbPathElements
import org.dashfoundation.example.navigation.QueryDetail
Expand Down Expand Up @@ -122,6 +123,19 @@ fun QueriesListScreen(navController: NavHostController) {
.clickable { navController.navigate(AddressQueries) }
.testTag("queries.row.addressQueries"),
)
ListItem(
headlineContent = { Text("Decision Proposals") },
supportingContent = {
Text(
"Browse, file, edit and vote on yes/no questions put to the " +
"masternodes (protocol version 15)",
style = MaterialTheme.typography.bodySmall,
)
},
modifier = Modifier
.clickable { navController.navigate(DecisionProposalsList) }
.testTag("votingQueries.decisionProposalsLink"),
)
ListItem(
headlineContent = { Text("Run All Queries") },
supportingContent = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.dashfoundation.example.ui.contracts

import org.dashfoundation.dashsdk.Sdk
import org.dashfoundation.dashsdk.voting.DecisionProposal
import org.dashfoundation.example.ui.identity.decode32ByteHex

/**
* The runnable platform-query registry β€” the Kotlin counterpart of the
Expand Down Expand Up @@ -33,6 +35,13 @@ internal class QueryDefinition(
val description: String,
val inputs: List<QueryInput>,
val diagnosticInputs: Map<String, String> = emptyMap(),
/**
* False keeps the query out of the Diagnostics "Run All" even when it
* needs no input: `DiagnosticsView.swift`'s `queriesToTest` does not run
* it, as for the decision proposal reads, which fail on any network
* below protocol version 15.
*/
val runsInDiagnostics: Boolean = true,
val execute: suspend (Sdk, Map<String, String>) -> String?,
)

Expand Down Expand Up @@ -490,6 +499,105 @@ internal val QUERY_REGISTRY: List<QueryDefinition> = listOf(
)
},

// ── Decision Proposals (protocol version 15) ──────────────────────────
// The typed reads of `Sdk.decisionProposals`, shown as their Kotlin
// values. `DecisionProposalsScreen` is the full flow (list, file, edit,
// vote); these rows run each read on its own.
QueryDefinition(
name = "getDecisionProposal",
label = "Get Decision Proposal",
description = "Fetch a decision proposal by its base58 id.",
inputs = listOf(QueryInput("proposalId", "Proposal ID", required = true)),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.fetch(inputs.getValue("proposalId").trim())?.toString()
},
QueryDefinition(
name = "getDecisionProposalsByEndDate",
label = "Get Decision Proposals by End Date",
description = "Proposals closing soonest first; the current time as the start lists the open ones.",
inputs = listOf(
QueryInput("endDateFromMs", "End date from (ms, included)", required = false),
QueryInput("endDateToMs", "End date before (ms)", required = false),
QueryInput("limit", "Limit", required = false, placeholder = "20"),
QueryInput("startAfter", "Start after proposal ID", required = false),
),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.listByEndDate(
endDateFromMs = inputs["endDateFromMs"]?.trim()?.toLongOrNull() ?: 0,
endDateToMs = inputs["endDateToMs"]?.trim()?.toLongOrNull() ?: 0,
limit = inputs["limit"]?.trim()?.toIntOrNull() ?: 0,
startAfter = inputs["startAfter"]?.trim()?.takeIf { it.isNotEmpty() },
).describeProposals()
},
QueryDefinition(
name = "getDecisionProposalsByOwner",
label = "Get Decision Proposals by Owner",
description = "The proposals an identity filed, in the order it filed them.",
inputs = listOf(
QueryInput("ownerId", "Owner Identity ID", required = true),
QueryInput("limit", "Limit", required = false, placeholder = "20"),
QueryInput("startAfter", "Start after proposal ID", required = false),
),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.listByOwner(
ownerId = inputs.getValue("ownerId").trim(),
limit = inputs["limit"]?.trim()?.toIntOrNull() ?: 0,
startAfter = inputs["startAfter"]?.trim()?.takeIf { it.isNotEmpty() },
).describeProposals()
},
QueryDefinition(
name = "getDecisionProposalsByCreatedAt",
label = "Get Decision Proposals by Creation",
description = "Every decision proposal, newest first.",
inputs = listOf(
QueryInput("limit", "Limit", required = false, placeholder = "20"),
QueryInput("startAfter", "Start after proposal ID", required = false),
),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.listByCreatedAt(
limit = inputs["limit"]?.trim()?.toIntOrNull() ?: 0,
startAfter = inputs["startAfter"]?.trim()?.takeIf { it.isNotEmpty() },
).describeProposals()
},
QueryDefinition(
name = "getDecisionProposalVotePollId",
label = "Get Decision Proposal Vote Poll ID",
description = "The id a proposal's yes/no poll is listed under.",
inputs = listOf(QueryInput("proposalId", "Proposal ID", required = true)),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.votePollId(inputs.getValue("proposalId").trim())
},
QueryDefinition(
name = "getDecisionProposalVotePollState",
label = "Get Decision Proposal Vote Poll State",
description = "The status, tallies and result of a proposal's yes/no poll.",
inputs = listOf(QueryInput("proposalId", "Proposal ID", required = true)),
runsInDiagnostics = false,
) { sdk, inputs ->
sdk.decisionProposals.votePollState(inputs.getValue("proposalId").trim())?.toString()
},
QueryDefinition(
name = "getMasternodeDecisionProposalVote",
label = "Get Masternode Decision Proposal Vote",
description = "A masternode's answer on a proposal while its poll is open.",
inputs = listOf(
QueryInput("proTxHash", "pro_tx_hash (64 hex chars, wire order)", required = true),
QueryInput("proposalId", "Proposal ID", required = true),
),
runsInDiagnostics = false,
) { sdk, inputs ->
val proTxHash = requireNotNull(decode32ByteHex(inputs.getValue("proTxHash"))) {
"pro_tx_hash must be 32 bytes (64 hex characters)"
}
sdk.decisionProposals.masternodeVote(proTxHash, inputs.getValue("proposalId").trim())
?.toString()
},

// ── Protocol & Version ────────────────────────────────────────────────
QueryDefinition(
name = "getProtocolVersionUpgradeState",
Expand Down Expand Up @@ -789,3 +897,8 @@ internal val QUERY_REGISTRY: List<QueryDefinition> = listOf(
)
},
)

/** A decision proposal listing page as text: its size, then one proposal per line. */
private fun List<DecisionProposal>.describeProposals(): String =
"$size proposal" + (if (size == 1) "" else "s") +
joinToString(separator = "\n", prefix = if (isEmpty()) "" else "\n")
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ fun DiagnosticsScreen(navController: NavHostController) {
results = emptyList()
progress = 0f
val runnable = QUERY_REGISTRY.filter { query ->
query.inputs.filter { it.required }
query.runsInDiagnostics && query.inputs.filter { it.required }
.all { query.diagnosticInputs.containsKey(it.name) }
}
val collected = ArrayList<QueryRunResult>(runnable.size)
Expand Down
Loading
Loading