From a842eee4767d2d545299ca52f3074bd29f25a36a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 10:43:59 +0300 Subject: [PATCH 1/5] feat(sdk): decision proposals in rs-sdk-ffi, Swift and Kotlin Step 5b of #5679: the mobile SDKs create, edit, list and read decision proposals, read a proposal's poll and a masternode's answer, and vote on a proposal with a masternode voting key. - rs-sdk-ffi: dash_sdk_decision_proposal_* (constants, build, properties JSON, fetch_document, build_edit, fetch, three listings, poll id, poll state, masternode answer, cast vote), marshalling over the rs-sdk helpers. The masternode vote key handling and failure diagnosis moved from cast_vote.rs to voting/masternode_vote.rs, shared by both votes. dash_sdk_voting_get_vote_polls_by_end_date writes each poll whole instead of only its end time. - rs-unified-sdk-jni: one JNI export per C function. - Swift: SDK reads, vote, build and edit; ManagedPlatformWallet create and replace; models; a SwiftExampleApp proposals screen; the DPNS end date query reads the new listing shape. - Kotlin: Sdk.decisionProposals, PlatformWalletManager.decisionProposals, models, a KotlinExampleApp proposals screen, parity and test plan rows. Co-Authored-By: Claude Opus 5.5 --- docs/protocol/decision-proposals.md | 7 +- .../kotlin-sdk/KotlinExampleApp/TEST_PLAN.md | 17 +- .../example/navigation/AppNavHost.kt | 14 + .../example/navigation/Routes.kt | 16 + .../example/ui/contracts/QueryDetailScreen.kt | 14 + .../example/ui/contracts/QueryRegistry.kt | 113 ++ .../ui/diagnostics/DiagnosticsScreen.kt | 2 +- .../ui/identity/ContestDetailScreen.kt | 7 +- .../identity/DecisionProposalDetailScreen.kt | 661 ++++++++++++ .../ui/identity/DecisionProposalsScreen.kt | 556 ++++++++++ packages/kotlin-sdk/PARITY.md | 17 +- .../dashsdk/DecisionProposalsFfiTest.kt | 152 +++ .../kotlin/org/dashfoundation/dashsdk/Sdk.kt | 9 + .../dashsdk/ffi/DecisionProposalsNative.kt | 164 +++ .../dashsdk/voting/DecisionProposalModels.kt | 400 +++++++ .../dashsdk/voting/DecisionProposals.kt | 312 ++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 12 + .../voting/DecisionProposalModelsTest.kt | 239 +++++ .../transitions/cast_vote.rs | 247 +---- .../rs-sdk-ffi/src/decision_proposal/json.rs | 411 ++++++++ .../rs-sdk-ffi/src/decision_proposal/mod.rs | 204 ++++ .../src/decision_proposal/queries.rs | 493 +++++++++ .../src/decision_proposal/transitions.rs | 991 ++++++++++++++++++ packages/rs-sdk-ffi/src/document/mod.rs | 5 +- packages/rs-sdk-ffi/src/lib.rs | 2 + .../rs-sdk-ffi/src/voting/masternode_vote.rs | 317 ++++++ packages/rs-sdk-ffi/src/voting/mod.rs | 1 + .../voting/queries/vote_polls_by_end_date.rs | 153 ++- .../src/decision_proposals.rs | 779 ++++++++++++++ packages/rs-unified-sdk-jni/src/lib.rs | 1 + ...nagedPlatformWalletDecisionProposals.swift | 92 ++ .../Voting/DecisionProposal.swift | 427 ++++++++ .../Voting/SDK+DecisionProposals.swift | 455 ++++++++ .../DecisionProposalDetailView.swift | 550 ++++++++++ .../DecisionProposalFormViews.swift | 415 ++++++++ .../DecisionProposalsView.swift | 260 +++++ .../Views/PlatformQueriesView.swift | 14 + .../Views/QueryDetailView.swift | 22 +- .../DecisionProposalDecodingTests.swift | 280 +++++ 39 files changed, 8560 insertions(+), 271 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt create mode 100644 packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt create mode 100644 packages/rs-sdk-ffi/src/decision_proposal/json.rs create mode 100644 packages/rs-sdk-ffi/src/decision_proposal/mod.rs create mode 100644 packages/rs-sdk-ffi/src/decision_proposal/queries.rs create mode 100644 packages/rs-sdk-ffi/src/decision_proposal/transitions.rs create mode 100644 packages/rs-sdk-ffi/src/voting/masternode_vote.rs create mode 100644 packages/rs-unified-sdk-jni/src/decision_proposals.rs create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalsView.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalDecodingTests.swift diff --git a/docs/protocol/decision-proposals.md b/docs/protocol/decision-proposals.md index 786ec1b610a..358ec3ffd26 100644 --- a/docs/protocol/decision-proposals.md +++ b/docs/protocol/decision-proposals.md @@ -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 diff --git a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md index 1cb02d3fa80..ac0940ba05c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md +++ b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md @@ -176,6 +176,12 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | 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`. Pays the decision proposal fund (0.5 Dash) plus fees. 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.`): 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`. Accepted for a day after creation; an edit that changes nothing is refused before anything is broadcast. | +| 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`. **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` @@ -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 | @@ -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 | @@ -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` @@ -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` diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt index 2c7c5005cf6..b55b17e3b2c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt @@ -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 @@ -472,6 +475,17 @@ fun AppNavHost( ContestDetailScreen(route.contestName, route.identityIdHex, navController) } + composable { DecisionProposalsScreen(navController) } + + composable { CreateDecisionProposalScreen(navController) } + + composable { entry -> + DecisionProposalDetailScreen( + entry.toRoute().proposalId, + navController, + ) + } + composable { entry -> val route = entry.toRoute() GroupDetailScreen(route.contractIdHex, route.position, navController) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt index 0d025c16782..f9f8ede75c4 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt @@ -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 diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryDetailScreen.kt index 7a87db0d613..edb8e45919b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryDetailScreen.kt @@ -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 @@ -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 = { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryRegistry.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryRegistry.kt index e1eb33e1a6e..58260589e73 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryRegistry.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/contracts/QueryRegistry.kt @@ -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 @@ -33,6 +35,13 @@ internal class QueryDefinition( val description: String, val inputs: List, val diagnosticInputs: Map = 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?, ) @@ -490,6 +499,105 @@ internal val QUERY_REGISTRY: List = 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", @@ -789,3 +897,8 @@ internal val QUERY_REGISTRY: List = listOf( ) }, ) + +/** A decision proposal listing page as text: its size, then one proposal per line. */ +private fun List.describeProposals(): String = + "$size proposal" + (if (size == 1) "" else "s") + + joinToString(separator = "\n", prefix = if (isEmpty()) "" else "\n") diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/diagnostics/DiagnosticsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/diagnostics/DiagnosticsScreen.kt index 2e76491bf76..f88db7a4f1f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/diagnostics/DiagnosticsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/diagnostics/DiagnosticsScreen.kt @@ -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(runnable.size) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt index 5665c365a2b..4943f7bb60b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt @@ -481,8 +481,11 @@ internal fun parseContestVoteState(json: String): ContestVoteState? = try { null } -/** Decode exactly 32 bytes of hex; null on any other shape. */ -private fun decode32ByteHex(input: String): ByteArray? { +/** + * Decode exactly 32 bytes of hex; null on any other shape. Shared with the + * decision proposal vote form, which collects the same masternode credentials. + */ +internal fun decode32ByteHex(input: String): ByteArray? { val trimmed = input.trim() if (trimmed.length != 64 || !trimmed.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) { return null diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt new file mode 100644 index 00000000000..4f4b28d3889 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt @@ -0,0 +1,661 @@ +package org.dashfoundation.example.ui.identity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import java.util.Date +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.voting.DecisionProposal +import org.dashfoundation.dashsdk.voting.DecisionProposalEdit +import org.dashfoundation.dashsdk.voting.YesNoAbstainVoteChoice +import org.dashfoundation.dashsdk.voting.YesNoMinimumVotingPower +import org.dashfoundation.dashsdk.voting.YesNoVotePollState +import org.dashfoundation.dashsdk.voting.YesNoVotePollStatus +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.ui.components.AccessiblePicker +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.ui.contracts.resolveSigning +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDate +import org.dashfoundation.example.util.truncateMiddle + +/** + * One decision proposal: port of `DecisionProposalDetailView.swift`. + * + * Reads the proposal, its poll's state and the poll's id through `Sdk.decisionProposals` + * (each independently, so one failing read leaves the others shown) and shows the rule, + * the tallies while voting is open and the result once it finished. Offers the masternode + * vote ([DecisionProposalVoteDialog], the same credentials [ContestDetailScreen] collects) + * and, when a local wallet identity filed the proposal, the text edit + * ([EditDecisionProposalDialog]). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DecisionProposalDetailScreen( + proposalId: String, + navController: NavHostController, +) { + val appState = LocalAppState.current + val container = LocalAppContainer.current + val sdk by appState.sdk.collectAsStateWithLifecycle() + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val walletIdentities by container.database.identityDao() + .observeWalletOwnedByNetwork(network.ffiValue) + .collectAsStateWithLifecycle(initialValue = emptyList()) + + var proposal by remember { mutableStateOf(null) } + var pollState by remember { mutableStateOf(null) } + var pollId by remember { mutableStateOf(null) } + var proposalError by remember { mutableStateOf(null) } + var pollError by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + var refreshTick by remember { mutableStateOf(0) } + + var showVote by remember { mutableStateOf(false) } + var showEdit by remember { mutableStateOf(false) } + var actionMessage by remember { mutableStateOf(null) } + + LaunchedEffect(sdk, proposalId, refreshTick) { + val currentSdk = sdk ?: run { + proposalError = "SDK not initialized" + return@LaunchedEffect + } + isLoading = true + proposalError = null + pollError = null + try { + proposal = readOrNull({ proposalError = "Loading the proposal failed: ${it.message}" }) { + currentSdk.decisionProposals.fetch(proposalId) + } + if (proposal == null && proposalError == null) { + proposalError = "Platform has no proposal $proposalId." + } + pollState = readOrNull({ pollError = "Loading the poll failed: ${it.message}" }) { + currentSdk.decisionProposals.votePollState(proposalId) + } + pollId = readOrNull({}) { currentSdk.decisionProposals.votePollId(proposalId) } + } finally { + isLoading = false + } + } + + // The local wallet identity that filed the proposal, which may edit it. + val owner = remember(proposal, walletIdentities) { + proposal?.let { shown -> + walletIdentities.firstOrNull { Base58.encode(it.identityId) == shown.ownerId } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Proposal") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton( + onClick = { refreshTick++ }, + enabled = !isLoading, + modifier = Modifier.testTag("decisionProposal.refresh"), + ) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + proposalError?.let { message -> + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("decisionProposal.error"), + ) + } + + val shown = proposal + if (shown != null) { + ProposalSection(shown, isOwnerLocal = owner != null) + RuleSection(shown) + } + + PollSection( + state = pollState, + pollId = pollId, + isLoading = isLoading, + error = pollError, + ) + + FormSection(title = "Actions") { + actionMessage?.let { message -> + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("decisionProposal.actionResult"), + ) + } + SubmitButton( + text = "Vote as a Masternode…", + isLoading = false, + enabled = sdk != null && shown != null, + modifier = Modifier.fillMaxWidth().testTag("decisionProposal.vote"), + ) { + actionMessage = null + showVote = true + } + Text( + "Only masternodes vote, with the voting key of a pro_tx_hash. A " + + "regular wallet has no voting key, so Platform refuses its vote.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (owner != null) { + SubmitButton( + text = "Edit Text…", + isLoading = false, + enabled = sdk != null && shown != null, + modifier = Modifier.fillMaxWidth().testTag("decisionProposal.edit"), + ) { + actionMessage = null + showEdit = true + } + Text( + "Platform accepts an edit of the title, description and url for a " + + "day after the proposal is created (or a fifth of its voting " + + "time, if that is shorter).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + val shown = proposal + if (showVote && shown != null) { + DecisionProposalVoteDialog( + proposal = shown, + onDismiss = { showVote = false }, + onVoted = { message -> + showVote = false + actionMessage = message + refreshTick++ // pull fresh tallies after an accepted vote + }, + ) + } + val editor = owner + if (showEdit && shown != null && editor != null) { + EditDecisionProposalDialog( + proposal = shown, + owner = editor, + onDismiss = { showEdit = false }, + onEdited = { message -> + showEdit = false + actionMessage = message + refreshTick++ + }, + ) + } +} + +/** + * [read]'s value, or null after handing its failure to [onError], so one failed read + * leaves the others shown. Cancellation is rethrown, never reported as a failure. + */ +private suspend fun readOrNull(onError: (Exception) -> Unit, read: suspend () -> T?): T? = + try { + read() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + onError(e) + null + } + +@Composable +private fun ProposalSection(proposal: DecisionProposal, isOwnerLocal: Boolean) { + FormSection(title = "Proposal") { + Text( + proposal.title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag("decisionProposal.title"), + ) + Text( + proposal.description, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("decisionProposal.description"), + ) + proposal.url?.let { LabeledContent("URL", it) } + LabeledContent("ID", truncateMiddle(proposal.id, 12, 8)) + LabeledContent( + "Proposer", + truncateMiddle(proposal.ownerId, 10, 6) + if (isOwnerLocal) " (local)" else "", + ) + proposal.revision?.let { LabeledContent("Revision", "$it") } + proposal.createdAtMs?.let { LabeledContent("Created", formatDate(Date(it))) } + proposal.updatedAtMs?.let { LabeledContent("Updated", formatDate(Date(it))) } + } +} + +@Composable +private fun RuleSection(proposal: DecisionProposal) { + FormSection(title = "Rule") { + LabeledContent("Passes when yes is more than", "${proposal.passPercent}% of yes plus no") + LabeledContent("Minimum turnout", "${proposal.minimumTurnoutPercent}% of voting power") + LabeledContent("Voting ends", formatDate(Date(proposal.endDateMs))) + Text( + "The rule and the end date never change after the proposal is created.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PollSection( + state: YesNoVotePollState?, + pollId: String?, + isLoading: Boolean, + error: String?, +) { + FormSection(title = "Poll") { + when { + error != null -> Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + state == null && isLoading -> LabeledContent("Status", "Loading…") + state == null -> Text( + "Platform has no poll for this proposal.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + else -> PollStateRows(state) + } + pollId?.let { LabeledContent("Poll ID", truncateMiddle(it, 12, 8)) } + Text( + "Voting power counts a masternode 1 and an evonode 4. Abstaining is recorded " + + "but plays no part in the result.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun PollStateRows(state: YesNoVotePollState) { + when (val status = state.status) { + is YesNoVotePollStatus.Started -> { + LabeledContent("Status", "Voting open", modifier = Modifier.testTag("decisionProposal.pollStatus")) + LabeledContent("Opened at block", "${status.startBlock.height}") + LabeledContent("Yes", "${state.yesVotingPower}") + LabeledContent("No", "${state.noVotingPower}") + LabeledContent("Abstain", "${state.abstainVotingPower}") + } + is YesNoVotePollStatus.Finished -> { + val result = status.result + LabeledContent( + "Status", + if (result.passed) "Passed" else "Did not pass", + modifier = Modifier.testTag("decisionProposal.pollStatus"), + ) + LabeledContent("Yes", "${result.yesVotingPower}") + LabeledContent("No", "${result.noVotingPower}") + LabeledContent("Abstain", "${result.abstainVotingPower}") + LabeledContent("Required yes plus no", "${result.requiredVotingPower}") + LabeledContent("Closed at block", "${result.finalizationBlock.height}") + } + } + val poll = state.votePoll + LabeledContent( + "Supermajority", + "more than ${poll.supermajorityNumerator}/${poll.supermajorityDenominator} of yes plus no", + ) + LabeledContent( + "Minimum voting power", + when (val minimum = poll.minimumVotingPower) { + is YesNoMinimumVotingPower.Absolute -> "${minimum.votingPower} voting power" + is YesNoMinimumVotingPower.FractionOfTotal -> + "${minimum.numerator}/${minimum.denominator} of total, rounded " + + minimum.rounding.jsonName + }, + ) +} + +/** + * Picks an answer and takes masternode credentials: port of `DecisionProposalVoteSheet` + * in `DecisionProposalDetailView.swift`. Votes through + * `Sdk.decisionProposals.castVote`, and reads the masternode's current answer through + * `Sdk.decisionProposals.masternodeVote`. + */ +@Composable +private fun DecisionProposalVoteDialog( + proposal: DecisionProposal, + onDismiss: () -> Unit, + onVoted: (String) -> Unit, +) { + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val sdk by appState.sdk.collectAsStateWithLifecycle() + + var choice by remember { mutableStateOf(YesNoAbstainVoteChoice.YES) } + var proTxHashHex by remember { mutableStateOf("") } + var votingKeyHex by remember { mutableStateOf("") } + var isBusy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + var messageIsError by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { if (!isBusy) onDismiss() }, + title = { Text("Vote · ${truncateMiddle(proposal.title, 20, 8)}") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + AccessiblePicker( + label = "Answer", + options = YesNoAbstainVoteChoice.entries, + selected = choice, + optionLabel = { it.label() }, + testTag = "decisionProposalVote.choice", + enabled = !isBusy, + onSelected = { choice = it }, + ) + OutlinedTextField( + value = proTxHashHex, + onValueChange = { proTxHashHex = it }, + label = { Text("pro_tx_hash (64 hex chars, wire order)") }, + singleLine = true, + enabled = !isBusy, + textStyle = MaterialTheme.typography.bodySmall + .copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth().testTag("decisionProposalVote.proTxHash"), + ) + OutlinedTextField( + value = votingKeyHex, + onValueChange = { votingKeyHex = it }, + label = { Text("voting private key (64 hex chars)") }, + singleLine = true, + enabled = !isBusy, + textStyle = MaterialTheme.typography.bodySmall + .copy(fontFamily = FontFamily.Monospace), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth().testTag("decisionProposalVote.votingKey"), + ) + Text( + "The pro_tx_hash goes in wire order, the reverse of the hex Core " + + "displays. The voting public key and signer are derived from the " + + "private key on the Rust side; the key bytes are not stored.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton( + enabled = !isBusy && proTxHashHex.isNotBlank(), + onClick = { + val currentSdk = sdk ?: return@TextButton + val proTxHash = decode32ByteHex(proTxHashHex) + if (proTxHash == null) { + message = "pro_tx_hash must be 32 bytes (64 hex characters)." + messageIsError = true + return@TextButton + } + isBusy = true + scope.launch { + try { + val answer = currentSdk.decisionProposals + .masternodeVote(proTxHash, proposal.id) + message = answer?.let { + "This masternode answered ${it.choice.label()} " + + "(${it.timesVoted} vote" + + (if (it.timesVoted == 1) "" else "s") + ")." + } ?: "This masternode has no answer on the open poll." + messageIsError = false + } catch (e: Exception) { + message = "Reading the answer failed: ${e.message}" + messageIsError = true + } finally { + isBusy = false + } + } + }, + modifier = Modifier.testTag("decisionProposalVote.lookUp"), + ) { Text("Read This Masternode's Answer") } + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = if (messageIsError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.testTag("decisionProposalVote.result"), + ) + } + } + }, + confirmButton = { + TextButton( + enabled = !isBusy && proTxHashHex.isNotBlank() && votingKeyHex.isNotBlank(), + onClick = { + val currentSdk = sdk ?: return@TextButton + val proTxHash = decode32ByteHex(proTxHashHex) + if (proTxHash == null) { + message = "pro_tx_hash must be 32 bytes (64 hex characters)." + messageIsError = true + return@TextButton + } + val votingKey = decode32ByteHex(votingKeyHex) + if (votingKey == null) { + message = "Voting private key must be 32 bytes (64 hex characters)." + messageIsError = true + return@TextButton + } + isBusy = true + scope.launch { + try { + currentSdk.decisionProposals.castVote( + proposalId = proposal.id, + choice = choice, + proTxHash = proTxHash, + votingPrivateKey = votingKey, + ) + onVoted("Vote accepted by Platform.") + } catch (e: Exception) { + message = "Vote rejected: ${e.message}" + messageIsError = true + } finally { + votingKey.fill(0) + isBusy = false + } + } + }, + modifier = Modifier.testTag("decisionProposalVote.submit"), + ) { Text(if (isBusy) "Broadcasting…" else "Submit Vote") } + }, + dismissButton = { + TextButton(enabled = !isBusy, onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** Display name of a vote answer. */ +private fun YesNoAbstainVoteChoice.label(): String = when (this) { + YesNoAbstainVoteChoice.YES -> "Yes" + YesNoAbstainVoteChoice.NO -> "No" + YesNoAbstainVoteChoice.ABSTAIN -> "Abstain" +} + +/** + * Gives a proposal a local identity filed new text: port of `EditDecisionProposalView` + * (`DecisionProposalFormViews.swift`). Submits through + * `PlatformWalletManager.decisionProposals.edit`: rs-sdk reads the stored proposal and + * applies the edit, refusing one that changes nothing, and the platform wallet replaces it, + * signing with the key [resolveSigning] picks, as the document replace form does. + */ +@Composable +private fun EditDecisionProposalDialog( + proposal: DecisionProposal, + owner: IdentityEntity, + onDismiss: () -> Unit, + onEdited: (String) -> Unit, +) { + val appState = LocalAppState.current + val container = LocalAppContainer.current + val scope = rememberCoroutineScope() + val sdk by appState.sdk.collectAsStateWithLifecycle() + + var title by remember { mutableStateOf(proposal.title) } + var description by remember { mutableStateOf(proposal.description) } + var url by remember { mutableStateOf(proposal.url.orEmpty()) } + var isSubmitting by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = { if (!isSubmitting) onDismiss() }, + title = { Text("Edit Proposal") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().testTag("editDecisionProposal.title"), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + minLines = 3, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().testTag("editDecisionProposal.description"), + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL (empty for none)") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth().testTag("editDecisionProposal.url"), + ) + Text( + "Only the title, description and url can change, and only for a day " + + "after the proposal was created (or a fifth of its voting time, if " + + "that is shorter).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + error?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("editDecisionProposal.error"), + ) + } + } + }, + confirmButton = { + TextButton( + enabled = !isSubmitting && sdk != null, + onClick = { + val currentSdk = sdk ?: return@TextButton + val proposalIdBytes = Base58.decodeIdentifier(proposal.id) + if (proposalIdBytes == null || proposalIdBytes.size != 32) { + error = "The proposal id is not a 32-byte base58 identifier." + return@TextButton + } + // An emptied url field removes the url; an untouched empty one + // keeps having none. + val trimmedUrl = url.trim() + val urlChange = when { + trimmedUrl.isNotEmpty() -> DecisionProposalEdit.UrlChange.Set(trimmedUrl) + proposal.url == null -> DecisionProposalEdit.UrlChange.Keep + else -> DecisionProposalEdit.UrlChange.Remove + } + val edit = DecisionProposalEdit( + title = title, + description = description, + url = urlChange, + ) + isSubmitting = true + error = null + scope.launch { + try { + val (walletHandle, mgr, signingKeyId) = resolveSigning(container, owner) + mgr.decisionProposals.edit( + sdk = currentSdk, + walletHandle = walletHandle, + ownerId = owner.identityId, + proposalId = proposalIdBytes, + edit = edit, + signingKeyId = signingKeyId, + signerHandle = mgr.signerHandle, + ) + onEdited("Edit accepted by Platform.") + } catch (e: Exception) { + error = e.message ?: "Edit failed" + } finally { + isSubmitting = false + } + } + }, + modifier = Modifier.testTag("editDecisionProposal.submitButton"), + ) { Text(if (isSubmitting) "Broadcasting…" else "Replace / Broadcast") } + }, + dismissButton = { + TextButton(enabled = !isSubmitting, onClick = onDismiss) { Text("Cancel") } + }, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt new file mode 100644 index 00000000000..0934a138415 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt @@ -0,0 +1,556 @@ +package org.dashfoundation.example.ui.identity + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import java.util.Date +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity +import org.dashfoundation.dashsdk.voting.DecisionProposal +import org.dashfoundation.dashsdk.voting.DecisionProposalParams +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.CreateDecisionProposal +import org.dashfoundation.example.navigation.DecisionProposalDetail +import org.dashfoundation.example.ui.components.AccessiblePicker +import org.dashfoundation.example.ui.components.ErrorAlertDialog +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.ui.contracts.parseDocumentId +import org.dashfoundation.example.ui.tokens.displayLabel +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDate +import org.dashfoundation.example.util.toHex +import org.dashfoundation.example.util.truncateMiddle + +/** The three ways the list reads proposals, each one Platform listing (← `Listing`). */ +private enum class ProposalListing(val label: String, val footer: String) { + OPEN("Open", "Proposals whose voting closes from now on, closing soonest first."), + MINE("Mine", "Proposals the selected identity filed, in the order it filed them."), + NEWEST("Newest", "Every proposal, newest first."), +} + +/** Proposals per page; a page shorter than this is the last. */ +private const val PROPOSAL_PAGE_SIZE = 20 + +/** + * Decision proposals (protocol version 15): port of `DecisionProposalsView.swift`. + * + * Lists proposals three ways, a page at a time, through `Sdk.decisionProposals`: the open + * ones closing soonest first (`listByEndDate` from now), the ones a local identity filed + * (`listByOwner`) and the newest (`listByCreatedAt`). Rows drill into + * [DecisionProposalDetailScreen]; the + action opens [CreateDecisionProposalScreen]. + * Nothing here decides what a proposal is. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DecisionProposalsScreen(navController: NavHostController) { + val appState = LocalAppState.current + val container = LocalAppContainer.current + val scope = rememberCoroutineScope() + val sdk by appState.sdk.collectAsStateWithLifecycle() + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val identities by container.database.identityDao() + .observeByNetwork(network.ffiValue) + .collectAsStateWithLifecycle(initialValue = emptyList()) + + var listing by remember { mutableStateOf(ProposalListing.OPEN) } + var owner by remember { mutableStateOf(null) } + var proposals by remember { mutableStateOf>(emptyList()) } + var hasMore by remember { mutableStateOf(false) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var refreshTick by remember { mutableStateOf(0) } + // Bumped on every fresh load so a late "load more" page of a previous + // listing is dropped instead of mixed in. + var loadGeneration by remember { mutableStateOf(0) } + + // Start "Mine" on the first local identity rather than empty. + LaunchedEffect(listing, identities) { + if (listing == ProposalListing.MINE && owner == null) owner = identities.firstOrNull() + } + + val ownerBase58 = owner?.let { Base58.encode(it.identityId) } + + LaunchedEffect(sdk, listing, ownerBase58, refreshTick) { + loadGeneration++ + proposals = emptyList() + hasMore = false + errorMessage = null + val currentSdk = sdk ?: run { + errorMessage = "SDK not initialized" + return@LaunchedEffect + } + if (listing == ProposalListing.MINE && ownerBase58 == null) return@LaunchedEffect + isLoading = true + try { + val page = loadProposalPage(currentSdk, listing, ownerBase58, startAfter = null) + proposals = page + hasMore = page.size == PROPOSAL_PAGE_SIZE + } catch (e: Exception) { + errorMessage = "Loading failed: ${e.message}" + } finally { + isLoading = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Decision Proposals") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton( + onClick = { refreshTick++ }, + enabled = !isLoading, + modifier = Modifier.testTag("decisionProposals.refresh"), + ) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + IconButton( + onClick = { navController.navigate(CreateDecisionProposal) }, + enabled = sdk != null, + modifier = Modifier.testTag("decisionProposals.create"), + ) { + Icon(Icons.Default.Add, contentDescription = "New decision proposal") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FormSection(title = "Listing") { + AccessiblePicker( + label = "Listing", + options = ProposalListing.entries, + selected = listing, + optionLabel = { it.label }, + testTag = "decisionProposals.listing", + onSelected = { listing = it }, + ) + if (listing == ProposalListing.MINE) { + if (identities.isEmpty()) { + Text( + "No local identity on this network.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + AccessiblePicker( + label = "Identity", + options = identities, + selected = owner ?: identities.first(), + optionLabel = { it.displayLabel() }, + testTag = "decisionProposals.ownerPicker", + onSelected = { owner = it }, + ) + } + } + Text( + listing.footer, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FormSection(title = "Proposals") { + errorMessage?.let { message -> + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("decisionProposals.error"), + ) + } + when { + isLoading && proposals.isEmpty() -> CircularProgressIndicator( + modifier = Modifier.padding(8.dp), + ) + listing == ProposalListing.MINE && ownerBase58 == null -> Text( + "Select an identity to list its proposals.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + proposals.isEmpty() && errorMessage == null -> Text( + "No proposals", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + proposals.forEach { proposal -> + DecisionProposalRow(proposal) { + navController.navigate(DecisionProposalDetail(proposal.id)) + } + } + if (hasMore) { + OutlinedButton( + onClick = { + val currentSdk = sdk ?: return@OutlinedButton + val last = proposals.lastOrNull() ?: return@OutlinedButton + val generation = loadGeneration + isLoading = true + scope.launch { + try { + val page = loadProposalPage( + currentSdk, + listing, + ownerBase58, + startAfter = last.id, + ) + if (generation == loadGeneration) { + proposals = proposals + page + hasMore = page.size == PROPOSAL_PAGE_SIZE + } + } catch (e: Exception) { + if (generation == loadGeneration) { + errorMessage = "Loading failed: ${e.message}" + } + } finally { + isLoading = false + } + } + }, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth().testTag("decisionProposals.loadMore"), + ) { + Text("Load More") + } + } + } + } + } +} + +/** One page of [listing] after [startAfter] (null for the first page). */ +private suspend fun loadProposalPage( + sdk: Sdk, + listing: ProposalListing, + ownerBase58: String?, + startAfter: String?, +): List = when (listing) { + ProposalListing.OPEN -> sdk.decisionProposals.listByEndDate( + endDateFromMs = System.currentTimeMillis(), + limit = PROPOSAL_PAGE_SIZE, + startAfter = startAfter, + ) + ProposalListing.MINE -> sdk.decisionProposals.listByOwner( + ownerId = requireNotNull(ownerBase58) { "no identity selected" }, + limit = PROPOSAL_PAGE_SIZE, + startAfter = startAfter, + ) + ProposalListing.NEWEST -> sdk.decisionProposals.listByCreatedAt( + limit = PROPOSAL_PAGE_SIZE, + startAfter = startAfter, + ) +} + +/** One proposal in a listing (← `DecisionProposalRow`). */ +@Composable +private fun DecisionProposalRow(proposal: DecisionProposal, onClick: () -> Unit) { + ListItem( + headlineContent = { Text(proposal.title, maxLines = 2) }, + supportingContent = { + Column { + Text( + "Ends ${formatDate(Date(proposal.endDateMs))} · passes above " + + "${proposal.passPercent}% · turnout ${proposal.minimumTurnoutPercent}%", + style = MaterialTheme.typography.bodySmall, + ) + Text( + truncateMiddle(proposal.id, 12, 8), + style = MaterialTheme.typography.labelSmall + .copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + modifier = Modifier + .clickable(onClick = onClick) + .testTag("decisionProposals.row.${proposal.id}"), + ) +} + +/** + * Files a new decision proposal for an identity held by a loaded wallet: port of + * `CreateDecisionProposalView` (`DecisionProposalFormViews.swift`). + * + * Submits through `PlatformWalletManager.decisionProposals.create`, which has rs-sdk build + * the proposal and the platform wallet put it; the create pays the decision proposal fund + * from the proposer's credits. Nothing in the form is checked here: Platform refuses a rule + * outside its limits. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateDecisionProposalScreen(navController: NavHostController) { + val appState = LocalAppState.current + val container = LocalAppContainer.current + val scope = rememberCoroutineScope() + val sdk by appState.sdk.collectAsStateWithLifecycle() + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + // Identities on the active network held by a loaded wallet, so the + // manager's signer can sign for them. + val identities by container.database.identityDao() + .observeWalletOwnedByNetwork(network.ffiValue) + .collectAsStateWithLifecycle(initialValue = emptyList()) + + var owner by remember { mutableStateOf(null) } + LaunchedEffect(identities) { + if (owner == null) owner = identities.firstOrNull() + } + var title by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var passPercent by remember { mutableStateOf("60") } + var turnoutPercent by remember { mutableStateOf("10") } + var endsInDays by remember { mutableStateOf("14") } + + var isSubmitting by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + var createdProposalId by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("New Proposal") }, + navigationIcon = { + IconButton( + onClick = { navController.popBackStack() }, + enabled = !isSubmitting, + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val createdId = createdProposalId + if (createdId != null) { + FormSection(title = "Proposal Created") { + Text( + "Broadcast confirmed on-chain.", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag("createDecisionProposal.success"), + ) + LabeledContent("ID", truncateMiddle(createdId, 12, 8)) + } + SubmitButton( + text = "Done", + isLoading = false, + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.doneButton"), + ) { navController.popBackStack() } + return@Column + } + + FormSection(title = "Proposer") { + if (identities.isEmpty()) { + Text( + "No identity held by a loaded wallet on this network.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + AccessiblePicker( + label = "Proposer", + options = identities, + selected = owner ?: identities.first(), + optionLabel = { it.displayLabel() }, + testTag = "createDecisionProposal.ownerPicker", + enabled = !isSubmitting, + onSelected = { owner = it }, + ) + } + Text( + "The create pays the decision proposal fund (0.5 Dash) from this " + + "identity's credits, on top of the fees.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FormSection(title = "Question") { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title: the question, in one line") }, + singleLine = true, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.title"), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description: what a yes and a no mean") }, + minLines = 3, + enabled = !isSubmitting, + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.description"), + ) + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("URL where it is discussed (optional)") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.url"), + ) + Text( + "The title, description and url can be edited for a day after the " + + "proposal is created.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FormSection(title = "Rule") { + OutlinedTextField( + value = passPercent, + onValueChange = { passPercent = it }, + label = { Text("Passes when yes is more than (% of yes plus no)") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.passPercent"), + ) + OutlinedTextField( + value = turnoutPercent, + onValueChange = { turnoutPercent = it }, + label = { Text("Minimum turnout (% of total voting power)") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.turnoutPercent"), + ) + OutlinedTextField( + value = endsInDays, + onValueChange = { endsInDays = it }, + label = { Text("Voting ends in (days from now)") }, + singleLine = true, + enabled = !isSubmitting, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.endDate"), + ) + Text( + "Yes must be more than the pass percent of yes plus no, and yes plus " + + "no must reach the turnout percent of the masternodes' total voting " + + "power. Platform accepts a pass percent of 50 to 80, a turnout of 0 " + + "to 50, and an end date one to four weeks after the block that " + + "creates the proposal. The rule never changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SubmitButton( + text = if (isSubmitting) "Broadcasting…" else "Create / Broadcast", + isLoading = isSubmitting, + enabled = sdk != null && manager != null && owner != null, + modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.submitButton"), + ) { + val currentSdk = sdk ?: return@SubmitButton + val mgr = manager ?: return@SubmitButton + val proposer = owner ?: return@SubmitButton + // Form parsing only: the limits on these values are Platform's. + val pass = passPercent.trim().toIntOrNull() + val turnout = turnoutPercent.trim().toIntOrNull() + val days = endsInDays.trim().toLongOrNull() + if (pass == null || turnout == null || days == null) { + error = "The pass percent, turnout and days must be whole numbers." + return@SubmitButton + } + val wallet = proposer.walletId?.toHex()?.let { mgr.wallets.value[it] } + if (wallet == null) { + error = "The proposer's wallet is not loaded in the wallet manager." + return@SubmitButton + } + val params = DecisionProposalParams( + title = title, + description = description, + url = url.trim().ifEmpty { null }, + passPercent = pass, + minimumTurnoutPercent = turnout, + endDateMs = System.currentTimeMillis() + days * MILLIS_PER_DAY, + ) + isSubmitting = true + scope.launch { + try { + val json = mgr.decisionProposals.create( + sdk = currentSdk, + walletHandle = wallet.handle, + ownerId = proposer.identityId, + params = params, + signerHandle = mgr.signerHandle, + ) + createdProposalId = parseDocumentId(json) ?: "(id in JSON)" + } catch (e: Exception) { + error = e.message ?: "Create failed" + } finally { + isSubmitting = false + } + } + } + } + } + + ErrorAlertDialog(message = error, onDismiss = { error = null }) +} + +private const val MILLIS_PER_DAY = 86_400_000L diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 1412fd9424a..5ef8bf20fe6 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -56,7 +56,7 @@ Status legend: | OptionsView.swift | ui/settings/SettingsScreen.kt · `SettingsHome` (+ AboutSheet.kt) | ported — Network/SPV/Data/Platform/About sections incl. About bottom sheet | | PendingPlatformFundFromAssetLocksList.swift | ui/funding/AddressFundProgressScreen.kt (`PendingAssetLocksList`) | ported (embedded in IdentitiesHome, matching iOS) | | PendingRegistrationsList.swift | ui/identity/RegistrationProgressScreen.kt (`PendingRegistrationRow`) | ported (embedded in IdentitiesHome) | -| PlatformQueriesView.swift | ui/contracts/QueryDetailScreen.kt (`QueriesListScreen`) · `QueriesList` | ported — the registry now spans the iOS query catalog (adapted: one flat Material list instead of 12 category groups) plus the dedicated GroveDB Path Elements / Address Queries / Run All Queries entries | +| PlatformQueriesView.swift | ui/contracts/QueryDetailScreen.kt (`QueriesListScreen`) · `QueriesList` | ported — the Decision Proposals link (`votingQueries.decisionProposalsLink`) opens `DecisionProposalsList`, and the seven decision proposal reads have registry rows kept out of Run All Queries; the registry now spans the iOS query catalog (adapted: one flat Material list instead of 12 category groups) plus the dedicated GroveDB Path Elements / Address Queries / Run All Queries entries | | PlatformStateTransitionsView.swift | ui/transitions/StateTransitionsScreen.kt · `StateTransitions` | ported (iOS file is a trivial wrapper around StateTransitionsView) | | QueryDetailView.swift | ui/contracts/QueryDetailScreen.kt · `QueryDetail` | ported for the bridged registry | | QuickBasicTokenView.swift | ui/tokens/QuickBasicTokenScreen.kt · `QuickBasicToken` | ported — contract-registration submit wired via `ManagedPlatformWallet.dataContracts.create` (`platform_wallet_create_data_contract_with_signer`) | @@ -120,6 +120,21 @@ verbatim. Documented deviations from iOS are listed after the table. **Tests:** `androidTest/DashPayTabUITest.kt` ports the network-free launch-and-render flows from `DashPayTabUITests.swift` (recognized-state gating + hub entry points), adapted for the nav-section deviation. +## Views/DecisionProposals/ + +Decision proposals (protocol version 15). The Kotlin SDK bridges every +`dash_sdk_decision_proposal_*` function one JNI export each +(`rs-unified-sdk-jni/src/decision_proposals.rs`); `Sdk.decisionProposals` +reads and votes, `PlatformWalletManager.decisionProposals` sequences the +create and the edit in Kotlin. Compile-verified; the device run waits on a +rebuilt `libdash_sdk_jni.so` and a network at protocol version 15. + +| Swift file | Android file / route | Status | +| --- | --- | --- | +| DecisionProposalsView.swift | ui/identity/DecisionProposalsScreen.kt · `DecisionProposalsList` | ported: Open / Mine / Newest listings, paged, over `Sdk.decisionProposals.listByEndDate` / `listByOwner` / `listByCreatedAt`; reached from the Platform Queries list (`votingQueries.decisionProposalsLink`) as on iOS | +| DecisionProposalDetailView.swift | ui/identity/DecisionProposalDetailScreen.kt · `DecisionProposalDetail` | ported: proposal, rule, poll state (open tallies or finished result) and poll id; masternode vote and answer lookup over `castVote` / `masternodeVote` with the contest screen's credential form | +| DecisionProposalFormViews.swift | ui/identity/DecisionProposalsScreen.kt · `CreateDecisionProposal` (create); ui/identity/DecisionProposalDetailScreen.kt (edit dialog) | ported: create through `PlatformWalletManager.decisionProposals.create` (build, properties JSON, free, then the wallet's document create); edit through `.edit` (fetch, build the edit, properties JSON, free, then the wallet's document replace). The end date is entered as days from now rather than with a date picker | + ## Views/Components/ | Swift file | Android file | Status | diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt new file mode 100644 index 00000000000..fe89c94bf34 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt @@ -0,0 +1,152 @@ +package org.dashfoundation.dashsdk + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.DecisionProposalsNative +import org.dashfoundation.dashsdk.ffi.NativeLoader +import org.dashfoundation.dashsdk.ffi.SdkNative +import org.dashfoundation.dashsdk.persistence.toBase58String +import org.dashfoundation.dashsdk.voting.YesNoAbstainVoteChoice +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Round trips of the decision proposal JNI exports through `rs-sdk-ffi` on a device: the + * symbol names, the argument marshalling and the error codes Kotlin maps. + * Every call here is refused before anything reaches the network (rs-sdk-ffi reads every + * argument first), so no network or protocol version 15 is needed: `createTrusted` only + * builds the client. The flows that reach Platform (build, fetch, vote) need a network at + * protocol version 15 and are exercised through KotlinExampleApp. + */ +@RunWith(AndroidJUnit4::class) +class DecisionProposalsFfiTest { + + /** A well-formed 32-byte id that names no proposal. */ + private val proposalId = ByteArray(32) { 0x11 }.toBase58String() + + private var handle = 0L + + @Before + fun createSdk() { + NativeLoader.ensureLoaded() + handle = SdkNative.createTrusted( + network = 1, + dapiAddresses = null, + quorumUrl = null, + skipAssetLockProofVerification = false, + requestRetryCount = 3, + requestTimeoutMs = 30_000, + platformVersion = 0, + ) + assertNotEquals("createTrusted must return a live handle", 0L, handle) + } + + @After + fun destroySdk() { + SdkNative.destroy(handle) + } + + @Test + fun shouldReadTheContractIdInBothEncodingsAndTheTypeName() { + val bytes = DecisionProposalsNative.contractIdBytes() + assertEquals(32, bytes.size) + assertEquals(bytes.toBase58String(), DecisionProposalsNative.contractId()) + assertTrue(DecisionProposalsNative.documentTypeName().isNotEmpty()) + } + + @Test + fun shouldFreeANullDocumentHandleAsANoOp() { + DecisionProposalsNative.documentFree(0L) + } + + /** rs-sdk-ffi refuses null document handles and bad ids with InvalidParameter (1). */ + @Test + fun shouldRefuseBadArgumentsBeforeTheNetwork() { + assertCode(1) { DecisionProposalsNative.propertiesJson(0L) } + assertCode(1) { + DecisionProposalsNative.buildEdit( + 0L, + DecisionProposalsNative.contractId(), + DecisionProposalsNative.documentTypeName(), + "new title", + null, + null, + false, + ) + } + assertCode(1) { DecisionProposalsNative.fetch(handle, "0OIl") } + assertCode(1) { DecisionProposalsNative.fetchDocument(handle, "0OIl") } + assertCode(1) { DecisionProposalsNative.getVotePollId(handle, "0OIl") } + assertCode(1) { DecisionProposalsNative.getVotePollState(handle, "0OIl") } + assertCode(1) { DecisionProposalsNative.listByOwner(handle, "0OIl", 0, null) } + assertCode(1) { DecisionProposalsNative.listByCreatedAt(handle, 0, "0OIl") } + assertCode(1) { + DecisionProposalsNative.build(handle, "0OIl", "title", "description", null, 60, 10, 1L) + } + } + + /** The JNI layer refuses a value the C type cannot hold instead of wrapping it. */ + @Test + fun shouldRefuseNumbersOutsideTheCTypes() { + assertCode(1) { + DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 300, 10, 1L) + } + assertCode(1) { + DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 60, -1, 1L) + } + assertCode(1) { + DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 60, 10, -1L) + } + assertCode(1) { DecisionProposalsNative.listByEndDate(handle, 0L, 0L, -1, null) } + assertCode(1) { DecisionProposalsNative.listByEndDate(handle, -1L, 0L, 0, null) } + } + + /** The C functions read exactly 32 bytes behind these pointers. */ + @Test + fun shouldRefuseAProTxHashOrVotingKeyThatIsNot32Bytes() { + assertCode(1) { DecisionProposalsNative.getMasternodeVote(handle, ByteArray(31), proposalId) } + assertCode(1) { + DecisionProposalsNative.castVote(handle, proposalId, 0, ByteArray(31), ByteArray(32), 1) + } + assertCode(1) { + DecisionProposalsNative.castVote(handle, proposalId, 0, ByteArray(32), ByteArray(33), 1) + } + } + + /** + * rs-sdk-ffi reads the vote choice before the voting key, so every Kotlin choice gets + * past the choice and is refused on the all-zero key instead, while a byte outside the + * three is refused as a choice. Nothing reaches the network either way. + */ + @Test + fun shouldAcceptExactlyTheThreeVoteChoices() { + for (choice in YesNoAbstainVoteChoice.entries) { + val error = assertCode(1) { + DecisionProposalsNative.castVote( + handle, proposalId, choice.discriminant, ByteArray(32) { 1 }, ByteArray(32), 1, + ) + } + assertFalse(error.message, error.message.orEmpty().contains("vote_choice")) + } + val refused = assertCode(1) { + DecisionProposalsNative.castVote(handle, proposalId, 3, ByteArray(32) { 1 }, ByteArray(32), 1) + } + assertTrue(refused.message, refused.message.orEmpty().contains("vote_choice")) + assertCode(1) { + DecisionProposalsNative.castVote(handle, proposalId, 256, ByteArray(32) { 1 }, ByteArray(32), 1) + } + } + + private fun assertCode(code: Int, call: () -> Unit): DashSDKException { + val error = assertThrows(DashSDKException::class.java) { call() } + assertEquals(error.message, code, error.code) + return error + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt index 0fe753eb68d..884a47212ca 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/Sdk.kt @@ -74,6 +74,15 @@ class Sdk private constructor( org.dashfoundation.dashsdk.queries.Voting(this) } + /** + * Decision proposal reads and the masternode vote on them (protocol version 15); + * mirrors the Swift SDK's `SDK+DecisionProposals.swift`. Creating and editing a + * proposal go through `PlatformWalletManager.decisionProposals`. + */ + val decisionProposals: org.dashfoundation.dashsdk.voting.DecisionProposals by lazy { + org.dashfoundation.dashsdk.voting.DecisionProposals(this) + } + /** Evonode proposed-epoch-block queries. */ val evonodes: org.dashfoundation.dashsdk.queries.Evonodes by lazy { org.dashfoundation.dashsdk.queries.Evonodes(this) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt new file mode 100644 index 00000000000..9d2b847d1a0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt @@ -0,0 +1,164 @@ +package org.dashfoundation.dashsdk.ffi + +/** + * What [DecisionProposalsNative.build] returns: the built proposal document's handle and + * the 32-byte entropy `dash_sdk_decision_proposal_build` drew for it. Constructed by + * `rs-unified-sdk-jni` with the descriptor `(J[B)V`. + * + * Native frees [documentHandle] itself when it cannot construct or return this object; + * once Kotlin holds it, Kotlin owns the handle and frees it with + * [DecisionProposalsNative.documentFree]. + */ +class DecisionProposalBuildNativeResult( + @JvmField val documentHandle: Long, + @JvmField val entropy: ByteArray, +) + +/** + * Raw JNI surface for decision proposals (protocol version 15): mirrors + * `rs-unified-sdk-jni/src/decision_proposals.rs`, one export per + * `dash_sdk_decision_proposal_*` function of `rs-sdk-ffi`, plus the document free its + * handles need. + * + * Internal: the public API is [org.dashfoundation.dashsdk.voting.DecisionProposals] (reads + * and the masternode vote) and [org.dashfoundation.dashsdk.voting.DecisionProposalTransactions] + * (create and edit), which sequence these calls; no export stitches two C calls together. + * Handles are raw Rust pointers as [Long]: `sdk` is `Sdk.handle`, and a document handle + * from [build], [fetchDocument] or [buildEdit] is freed exactly once with [documentFree]. + * Ids are base58 strings except where a `ByteArray` is named. Errors throw + * [DashSDKException]. + */ +internal object DecisionProposalsNative { + + /** The decision proposals system contract id, base58. */ + external fun contractId(): String + + /** The 32 bytes of the decision proposals system contract id. */ + external fun contractIdBytes(): ByteArray + + /** The document type name of a proposal (`"proposal"`). */ + external fun documentTypeName(): String + + /** + * Build a decision proposal of [ownerIdentityId] asking [title], with its rule. Nothing + * is broadcast. Bridges `dash_sdk_decision_proposal_build`. + * + * @param url null for none. + * @param passPercent must fit a `u8`; Platform's limits apply. + * @param minimumTurnoutPercent must fit a `u8`; Platform's limits apply. + * @param endDateMs milliseconds since the Unix epoch; must be non-negative. + * @return the document handle (free with [documentFree]) and its entropy. + */ + external fun build( + sdk: Long, + ownerIdentityId: String, + title: String, + description: String, + url: String?, + passPercent: Int, + minimumTurnoutPercent: Int, + endDateMs: Long, + ): DecisionProposalBuildNativeResult + + /** + * The properties of [document] as the JSON object the platform wallet's document create + * and replace take. Bridges `dash_sdk_decision_proposal_properties_json`; the caller still + * owns [document]. + */ + external fun propertiesJson(document: Long): String? + + /** + * The stored proposal [proposalId] as a document handle (free with [documentFree]), or 0 + * when there is none. Bridges `dash_sdk_decision_proposal_fetch_document`. + */ + external fun fetchDocument(sdk: Long, proposalId: String): Long + + /** + * A copy of [proposal] with new text and its next revision, as a new document handle + * (free with [documentFree]). No network. Bridges `dash_sdk_decision_proposal_build_edit`. + * + * @param title null keeps it. + * @param description null keeps it. + * @param url null keeps it, unless [removeUrl]. + * @param removeUrl removes the url; rs-sdk-ffi refuses it together with a non-null [url]. + */ + external fun buildEdit( + proposal: Long, + dataContractId: String, + documentTypeName: String, + title: String?, + description: String?, + url: String?, + removeUrl: Boolean, + ): Long + + /** Free a document handle from [build], [fetchDocument] or [buildEdit]. Safe on 0. */ + external fun documentFree(document: Long) + + /** A proposal as JSON, or null when there is none. Bridges `dash_sdk_decision_proposal_fetch`. */ + external fun fetch(sdk: Long, proposalId: String): String? + + /** + * One page of proposals by end date, closing soonest first, as a JSON array. + * [endDateFromMs] is included, [endDateToMs] left out; 0 is no bound. [limit] 0 is 100. + * Bridges `dash_sdk_decision_proposal_list_by_end_date`. + */ + external fun listByEndDate( + sdk: Long, + endDateFromMs: Long, + endDateToMs: Long, + limit: Int, + startAfter: String?, + ): String? + + /** + * One page of [ownerId]'s proposals in filing order, as a JSON array. Bridges + * `dash_sdk_decision_proposal_list_by_owner`. + */ + external fun listByOwner(sdk: Long, ownerId: String, limit: Int, startAfter: String?): String? + + /** + * One page of every proposal, newest first, as a JSON array. Bridges + * `dash_sdk_decision_proposal_list_by_created_at`. + */ + external fun listByCreatedAt(sdk: Long, limit: Int, startAfter: String?): String? + + /** + * The id of [proposalId]'s yes/no poll, as a plain base58 string. Bridges + * `dash_sdk_decision_proposal_get_vote_poll_id`. + */ + external fun getVotePollId(sdk: Long, proposalId: String): String? + + /** + * The status and tallies of [proposalId]'s poll as JSON, or null when it was never + * opened. Bridges `dash_sdk_decision_proposal_get_vote_poll_state`. + */ + external fun getVotePollState(sdk: Long, proposalId: String): String? + + /** + * A masternode's answer on [proposalId] as `{"choice", "timesVoted"}`, or null when it + * has not voted or the poll finished. Bridges + * `dash_sdk_decision_proposal_get_masternode_vote`. + * + * @param voterProTxHash 32 bytes in wire order, as [castVote] takes it. + */ + external fun getMasternodeVote(sdk: Long, voterProTxHash: ByteArray, proposalId: String): String? + + /** + * Vote on [proposalId] for a masternode and wait for the response. Bridges + * `dash_sdk_decision_proposal_cast_vote`. + * + * @param voteChoice `0` yes, `1` no, `2` abstain. + * @param voterProTxHash 32 bytes in wire order (the reverse of the hex Core displays). + * @param votingPrivateKey the 32-byte masternode voting private key; not stored. + * @param networkOrd `Network.ffiValue`. + */ + external fun castVote( + sdk: Long, + proposalId: String, + voteChoice: Int, + voterProTxHash: ByteArray, + votingPrivateKey: ByteArray, + networkOrd: Int, + ) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt new file mode 100644 index 00000000000..400d0ad2f41 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt @@ -0,0 +1,400 @@ +package org.dashfoundation.dashsdk.voting + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.longOrNull +import org.dashfoundation.dashsdk.errors.DashSdkError + +// The types the decision proposal reads return, decoded from the JSON rs-sdk-ffi writes +// (`packages/rs-sdk-ffi/src/decision_proposal/json.rs`). Rust decides every value; these +// types only carry it. Names and shapes follow the Swift SDK +// (`SwiftDashSDK/Voting/DecisionProposal.swift`) and rs-dpp. + +/** + * A decision proposal (protocol version 15): a yes/no question an identity put to the + * masternodes, a `proposal` document of the decision proposals system contract. + * + * The proposal passes when the yes voting power is strictly more than [passPercent] percent + * of yes plus no, and yes plus no reaches [minimumTurnoutPercent] percent of the masternode + * list's total voting power when voting closes, rounded up. The title, description and url + * can be edited for a day after the proposal is created; the rule and [endDateMs] never + * change. + */ +data class DecisionProposal( + /** The proposal's id, base58. */ + val id: String, + /** The proposer's identity id, base58. */ + val ownerId: String, + /** The document revision; null when the document does not carry one. */ + val revision: Long?, + /** When the proposal was created, in milliseconds since the Unix epoch; may be null. */ + val createdAtMs: Long?, + /** When the proposal was last changed, in milliseconds since the Unix epoch; may be null. */ + val updatedAtMs: Long?, + /** The question, in one line. */ + val title: String, + /** What a yes and a no mean. */ + val description: String, + /** Where the proposal is discussed; null when it has none. */ + val url: String?, + /** The share of yes plus no, in percent, the yes voting power must strictly exceed. */ + val passPercent: Int, + /** The least yes plus no, in percent of the total voting power, the proposal needs. */ + val minimumTurnoutPercent: Int, + /** When voting closes, in milliseconds since the Unix epoch. */ + val endDateMs: Long, +) { + internal companion object { + /** Decode the proposal object every decision proposal read returns. */ + fun fromJson(json: String): DecisionProposal = + fromJsonObject(DecisionProposalJson.parseObject(json, "decision proposal")) + + /** Decode a listing: a JSON array of proposal objects, in the listing's order. */ + fun listFromJson(json: String): List { + val array = DecisionProposalJson.parse(json, "decision proposal listing") as? JsonArray + ?: throw DashSdkError.SerializationError("Malformed decision proposal listing: $json") + return array.map { entry -> + fromJsonObject( + entry as? JsonObject + ?: throw DashSdkError.SerializationError("Malformed decision proposal: $entry"), + ) + } + } + + private fun fromJsonObject(json: JsonObject): DecisionProposal = with(DecisionProposalJson) { + DecisionProposal( + id = json.string("id"), + ownerId = json.string("ownerId"), + revision = json.optionalLong("revision"), + createdAtMs = json.optionalLong("createdAt"), + updatedAtMs = json.optionalLong("updatedAt"), + title = json.string("title"), + description = json.string("description"), + url = json.optionalString("url"), + passPercent = json.int("passPercent"), + minimumTurnoutPercent = json.int("minimumTurnoutPercent"), + endDateMs = json.long("endDate"), + ) + } + } +} + +/** + * What a new decision proposal asks and the rule it is decided by, the input of + * [DecisionProposalTransactions.create]. Kotlin mirror of the C + * `DashSDKDecisionProposalParams` and Swift's `DecisionProposalParams`. Nothing is checked + * here: Platform refuses a title of more than 128 characters, a description of more than + * 4096, a [passPercent] outside 50 to 80, a [minimumTurnoutPercent] above 50 and an + * [endDateMs] less than a week or more than four weeks after the block that creates the + * proposal. + */ +data class DecisionProposalParams( + /** The question, in one line. */ + val title: String, + /** What a yes and a no mean. */ + val description: String, + /** Where the proposal is discussed; null for none. */ + val url: String? = null, + /** The share of yes plus no, in percent, the yes voting power must strictly exceed. */ + val passPercent: Int, + /** The least yes plus no, in percent of the total voting power, the proposal needs. */ + val minimumTurnoutPercent: Int, + /** When voting closes, in milliseconds since the Unix epoch. */ + val endDateMs: Long, +) + +/** + * New text for a stored decision proposal, the input of [DecisionProposalTransactions.edit]. + * A field left null keeps its value. Kotlin mirror of Swift's `DecisionProposalEdit`; rs-sdk + * refuses an edit that changes nothing, and Platform refuses one more than a day after the + * proposal was created. + */ +data class DecisionProposalEdit( + /** The new title; null keeps it. */ + val title: String? = null, + /** The new description; null keeps it. */ + val description: String? = null, + /** What happens to the url. */ + val url: UrlChange = UrlChange.Keep, +) { + /** What an edit does to a proposal's url. */ + sealed interface UrlChange { + /** The url stays as it is. */ + data object Keep : UrlChange + + /** The url becomes [url]. */ + data class Set(val url: String) : UrlChange + + /** The proposal no longer has a url. */ + data object Remove : UrlChange + } +} + +/** + * A masternode's answer on a decision proposal. [discriminant] is the `vote_choice` byte of + * `dash_sdk_decision_proposal_cast_vote` (`0` yes, `1` no, `2` abstain); the header declares + * no enum, so the three values are mirrored here as Swift's `YesNoAbstainVoteChoice` mirrors + * them. [jsonName] is how the masternode answer JSON names the choice. + */ +enum class YesNoAbstainVoteChoice(val discriminant: Int, val jsonName: String) { + YES(0, "yes"), + NO(1, "no"), + ABSTAIN(2, "abstain"), + ; + + internal companion object { + fun fromJsonName(name: String): YesNoAbstainVoteChoice = + entries.firstOrNull { it.jsonName == name } + ?: throw DashSdkError.SerializationError("Unknown yes/no vote choice: $name") + } +} + +/** A masternode's answer on a decision proposal's poll. */ +data class MasternodeYesNoVote( + /** The answer it gave last. */ + val choice: YesNoAbstainVoteChoice, + /** How many times it has voted on the poll, the first vote included. */ + val timesVoted: Int, +) { + internal companion object { + /** Decode `{"choice": "yes" | "no" | "abstain", "timesVoted": 2}`. */ + fun fromJson(json: String): MasternodeYesNoVote = with(DecisionProposalJson) { + val obj = parseObject(json, "masternode vote") + MasternodeYesNoVote( + choice = YesNoAbstainVoteChoice.fromJsonName(obj.string("choice")), + timesVoted = obj.int("timesVoted"), + ) + } + } +} + +/** A block, as a poll records when it opened and when it closed. */ +data class PlatformBlockInfo( + val height: Long, + val coreHeight: Long, + /** The block time, in milliseconds since the Unix epoch. */ + val timeMs: Long, + val epoch: Int, +) + +/** Which way a fraction of the total voting power rounds to a whole voting power (rs-dpp). */ +enum class VotingPowerRounding(val jsonName: String) { + /** Toward zero. */ + DOWN("down"), + + /** Away from zero. */ + UP("up"), + + /** Rounded down, plus one. */ + DOWN_PLUS_ONE("downPlusOne"), + + /** Rounded up, minus one, never below zero. */ + UP_MINUS_ONE("upMinusOne"), +} + +/** The least yes plus no voting power a yes/no poll needs to pass (rs-dpp). */ +sealed interface YesNoMinimumVotingPower { + /** A fixed voting power. */ + data class Absolute(val votingPower: Long) : YesNoMinimumVotingPower + + /** + * A share of the masternode list's total voting power in the block that closes the + * poll, rounded as [rounding] says. + */ + data class FractionOfTotal( + val numerator: Int, + val denominator: Int, + val rounding: VotingPowerRounding, + ) : YesNoMinimumVotingPower +} + +/** What a yes/no poll decides (rs-dpp `YesNoVotePollPurpose`). */ +sealed interface YesNoVotePollPurpose { + /** The poll a decision proposal opens; [documentId] is the proposal's id, base58. */ + data class DecisionProposal(val documentId: String) : YesNoVotePollPurpose +} + +/** A yes/no poll as it was opened, rule included (rs-dpp `YesNoVotePoll`). */ +data class YesNoVotePoll( + val purpose: YesNoVotePollPurpose, + /** With [supermajorityDenominator], the share of yes plus no the yes power must exceed. */ + val supermajorityNumerator: Int, + val supermajorityDenominator: Int, + val minimumVotingPower: YesNoMinimumVotingPower, +) + +/** The record a finished yes/no poll keeps (rs-dpp `YesNoVotePollResult`). */ +data class YesNoVotePollResult( + val passed: Boolean, + val yesVotingPower: Long, + val noVotingPower: Long, + val abstainVotingPower: Long, + /** The yes plus no voting power the poll needed, resolved in the block that closed it. */ + val requiredVotingPower: Long, + val startBlock: PlatformBlockInfo, + val finalizationBlock: PlatformBlockInfo, +) + +/** Whether a yes/no poll is open or has finished. */ +sealed interface YesNoVotePollStatus { + /** Voting is open; the poll opened in [startBlock]. */ + data class Started(val startBlock: PlatformBlockInfo) : YesNoVotePollStatus + + /** The poll has finished, with its record. */ + data class Finished(val result: YesNoVotePollResult) : YesNoVotePollStatus +} + +/** + * The status and tallies of a decision proposal's yes/no poll + * (`dash_sdk_decision_proposal_get_vote_poll_state`). Voting powers count a masternode 1 + * and an evonode 4. + */ +data class YesNoVotePollState( + /** The poll as it was opened, rule included. */ + val votePoll: YesNoVotePoll, + val status: YesNoVotePollStatus, + /** The yes voting power so far; 0 once the poll finished (read its result then). */ + val yesVotingPower: Long, + /** The no voting power so far; 0 once the poll finished. */ + val noVotingPower: Long, + /** The abstaining voting power so far; 0 once the poll finished. */ + val abstainVotingPower: Long, +) { + /** The poll's record once it finished; null while it is open. */ + val result: YesNoVotePollResult? + get() = (status as? YesNoVotePollStatus.Finished)?.result + + internal companion object { + /** Decode the poll state object. */ + fun fromJson(json: String): YesNoVotePollState = with(DecisionProposalJson) { + val obj = parseObject(json, "yes/no vote poll state") + val status = when (val name = obj.string("status")) { + "started" -> YesNoVotePollStatus.Started(block(obj.obj("startBlock"))) + "finished" -> YesNoVotePollStatus.Finished(result(obj.obj("result"))) + else -> throw DashSdkError.SerializationError("Unknown yes/no vote poll status: $name") + } + YesNoVotePollState( + votePoll = votePoll(obj.obj("votePoll")), + status = status, + yesVotingPower = obj.long("yesVotingPower"), + noVotingPower = obj.long("noVotingPower"), + abstainVotingPower = obj.long("abstainVotingPower"), + ) + } + + private fun votePoll(obj: JsonObject): YesNoVotePoll = with(DecisionProposalJson) { + YesNoVotePoll( + purpose = purpose(obj.obj("purpose")), + supermajorityNumerator = obj.int("supermajorityNumerator"), + supermajorityDenominator = obj.int("supermajorityDenominator"), + minimumVotingPower = minimumVotingPower(obj.obj("minimumVotingPower")), + ) + } + + private fun purpose(obj: JsonObject): YesNoVotePollPurpose = with(DecisionProposalJson) { + when (val type = obj.string("\$type")) { + "decisionProposal" -> YesNoVotePollPurpose.DecisionProposal(obj.string("documentId")) + else -> throw DashSdkError.SerializationError("Unknown yes/no vote poll purpose: $type") + } + } + + private fun minimumVotingPower(obj: JsonObject): YesNoMinimumVotingPower = + with(DecisionProposalJson) { + when (val type = obj.string("\$type")) { + "absolute" -> YesNoMinimumVotingPower.Absolute(obj.long("votingPower")) + "fractionOfTotal" -> YesNoMinimumVotingPower.FractionOfTotal( + numerator = obj.int("numerator"), + denominator = obj.int("denominator"), + rounding = obj.string("rounding").let { name -> + VotingPowerRounding.entries.firstOrNull { it.jsonName == name } + ?: throw DashSdkError.SerializationError( + "Unknown voting power rounding: $name", + ) + }, + ) + else -> throw DashSdkError.SerializationError( + "Unknown minimum voting power: $type", + ) + } + } + + private fun result(obj: JsonObject): YesNoVotePollResult = with(DecisionProposalJson) { + YesNoVotePollResult( + passed = obj.boolean("passed"), + yesVotingPower = obj.long("yesVotingPower"), + noVotingPower = obj.long("noVotingPower"), + abstainVotingPower = obj.long("abstainVotingPower"), + requiredVotingPower = obj.long("requiredVotingPower"), + startBlock = block(obj.obj("startBlock")), + finalizationBlock = block(obj.obj("finalizationBlock")), + ) + } + + private fun block(obj: JsonObject): PlatformBlockInfo = with(DecisionProposalJson) { + PlatformBlockInfo( + height = obj.long("height"), + coreHeight = obj.long("coreHeight"), + timeMs = obj.long("timeMs"), + epoch = obj.int("epoch"), + ) + } + } +} + +/** + * Strict field readers for the decision proposal JSON: a missing or mistyped field is a + * [DashSdkError.SerializationError] naming it, never a default value standing in for what + * Rust wrote. + */ +internal object DecisionProposalJson { + fun parse(json: String, what: String): JsonElement = try { + Json.parseToJsonElement(json) + } catch (e: SerializationException) { + throw DashSdkError.SerializationError("Malformed $what JSON: $json", e) + } + + fun parseObject(json: String, what: String): JsonObject = + parse(json, what) as? JsonObject + ?: throw DashSdkError.SerializationError("Malformed $what JSON: $json") + + fun JsonObject.obj(name: String): JsonObject = + this[name] as? JsonObject ?: throw malformed(name) + + fun JsonObject.string(name: String): String = + (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: throw malformed(name) + + fun JsonObject.optionalString(name: String): String? = when (val value = this[name]) { + null, JsonNull -> null + is JsonPrimitive -> if (value.isString) value.content else throw malformed(name) + else -> throw malformed(name) + } + + fun JsonObject.long(name: String): Long = number(this[name]) ?: throw malformed(name) + + fun JsonObject.optionalLong(name: String): Long? = when (val value = this[name]) { + null, JsonNull -> null + else -> number(value) ?: throw malformed(name) + } + + fun JsonObject.int(name: String): Int { + val value = long(name) + if (value < Int.MIN_VALUE || value > Int.MAX_VALUE) throw malformed(name) + return value.toInt() + } + + fun JsonObject.boolean(name: String): Boolean = + (this[name] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull + ?: throw malformed(name) + + private fun number(value: JsonElement?): Long? = + (value as? JsonPrimitive)?.takeUnless { it.isString || it is JsonNull }?.longOrNull + + private fun malformed(name: String) = + DashSdkError.SerializationError("Decision proposal JSON has no valid \"$name\"") +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt new file mode 100644 index 00000000000..6998d33814a --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt @@ -0,0 +1,312 @@ +package org.dashfoundation.dashsdk.voting + +import org.dashfoundation.dashsdk.Sdk +import org.dashfoundation.dashsdk.documents.DocumentTransactions +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.errors.mapNativeErrors +import org.dashfoundation.dashsdk.ffi.DecisionProposalsNative +import org.dashfoundation.dashsdk.persistence.toBase58String +import org.dashfoundation.dashsdk.wallet.TeardownGate +import org.dashfoundation.dashsdk.wallet.op + +/** + * Decision proposals (protocol version 15): yes/no questions any identity puts to the + * masternodes, as `proposal` documents of the decision proposals system contract. Reads of + * proposals and their polls, and the masternode vote. Kotlin counterpart of the Swift + * SDK's `SDK+DecisionProposals.swift`; reach it through `Sdk.decisionProposals`. Creating + * and editing a proposal go through the wallet: [DecisionProposalTransactions]. + * + * Thin wrapper over [DecisionProposalsNative]: rs-sdk decides everything (which documents + * are proposals, the poll's id, the vote), every read is proved, and nothing is checked + * here. Every call runs under the SDK's query gate, so closing the SDK waits for it. Ids + * are base58 strings. A network below protocol version 15 has no decision proposals + * contract, which the calls report as an error. + */ +class DecisionProposals internal constructor(private val sdk: Sdk) { + + /** The decision proposals system contract id, base58. */ + val contractId: String by lazy { DecisionProposalsNative.contractId() } + + /** The document type name of a proposal. */ + val documentTypeName: String by lazy { DecisionProposalsNative.documentTypeName() } + + /** The contract id's 32 bytes, as the platform wallet's document calls take it. */ + internal val contractIdBytes: ByteArray + get() = DecisionProposalsNative.contractIdBytes() + + /** The proposal [proposalId], or null when there is none. */ + suspend fun fetch(proposalId: String): DecisionProposal? = sdk.queryGate.op { + mapNativeErrors { DecisionProposalsNative.fetch(sdk.handle, proposalId) } + ?.let(DecisionProposal::fromJson) + } + + /** + * One page of the proposals whose end date is within the bounds, closing soonest first + * (by end date, then by creation time). The current time as [endDateFromMs] lists the + * open proposals. + * + * @param endDateFromMs the earliest end date listed, included; 0 for no lower bound. + * @param endDateToMs the end date the listing stops before; 0 for no upper bound. + * @param limit the most proposals to return, at most 100; 0 for 100. A page shorter + * than the limit is the last. + * @param startAfter the id of the last proposal of the previous page; null for the first. + */ + suspend fun listByEndDate( + endDateFromMs: Long = 0, + endDateToMs: Long = 0, + limit: Int = 0, + startAfter: String? = null, + ): List = sdk.queryGate.op { + val json = mapNativeErrors { + DecisionProposalsNative.listByEndDate( + sdk.handle, + endDateFromMs, + endDateToMs, + limit, + startAfter, + ) + } + DecisionProposal.listFromJson(json.returned("listByEndDate")) + } + + /** One page of [ownerId]'s proposals, in the order it filed them; paging as [listByEndDate]. */ + suspend fun listByOwner( + ownerId: String, + limit: Int = 0, + startAfter: String? = null, + ): List = sdk.queryGate.op { + val json = mapNativeErrors { + DecisionProposalsNative.listByOwner(sdk.handle, ownerId, limit, startAfter) + } + DecisionProposal.listFromJson(json.returned("listByOwner")) + } + + /** One page of every proposal, newest first; paging as [listByEndDate]. */ + suspend fun listByCreatedAt( + limit: Int = 0, + startAfter: String? = null, + ): List = sdk.queryGate.op { + val json = mapNativeErrors { + DecisionProposalsNative.listByCreatedAt(sdk.handle, limit, startAfter) + } + DecisionProposal.listFromJson(json.returned("listByCreatedAt")) + } + + /** + * The id of the yes/no poll [proposalId] opens, base58: the id + * `Voting.votePollsByEndDate` lists it under. + */ + suspend fun votePollId(proposalId: String): String = sdk.queryGate.op { + mapNativeErrors { DecisionProposalsNative.getVotePollId(sdk.handle, proposalId) } + .returned("getVotePollId") + } + + /** The status and tallies of [proposalId]'s poll, or null for a proposal never created. */ + suspend fun votePollState(proposalId: String): YesNoVotePollState? = sdk.queryGate.op { + mapNativeErrors { DecisionProposalsNative.getVotePollState(sdk.handle, proposalId) } + ?.let(YesNoVotePollState::fromJson) + } + + /** + * A masternode's answer on [proposalId], or null when it has not voted on it, and once + * the poll finished (which removes its votes). + * + * @param proTxHash the masternode's 32-byte pro_tx_hash in wire order, as [castVote] + * takes it. + */ + suspend fun masternodeVote(proTxHash: ByteArray, proposalId: String): MasternodeYesNoVote? = + sdk.queryGate.op { + mapNativeErrors { + DecisionProposalsNative.getMasternodeVote(sdk.handle, proTxHash, proposalId) + }?.let(MasternodeYesNoVote::fromJson) + } + + /** + * Vote on [proposalId] for a masternode and wait for the response: the vote is signed + * with the masternode voting key derived from [votingPrivateKey] and broadcast for + * [proTxHash]. A masternode may vote 5 times on a proposal, but not give the answer it + * already gave. A vote from a wallet that is not a masternode is refused by Platform + * (no voter identity), the expected outcome. + * + * @param proTxHash 32-byte pro_tx_hash in wire order: the orientation `Txid` stores, + * which a parsed ProRegTx yields. NOT the byte order of the hex Core displays, which + * is its reverse (see [VoteCasting.castVote]). + * @param votingPrivateKey 32-byte masternode voting private key; not stored. + */ + suspend fun castVote( + proposalId: String, + choice: YesNoAbstainVoteChoice, + proTxHash: ByteArray, + votingPrivateKey: ByteArray, + ) = sdk.queryGate.op { + mapNativeErrors { + DecisionProposalsNative.castVote( + sdk.handle, + proposalId, + choice.discriminant, + proTxHash, + votingPrivateKey, + sdk.network.ffiValue, + ) + } + } + + /** + * The properties a new proposal of [ownerId] is created with: builds the proposal + * document (`build`), reads its properties (`propertiesJson`) and frees the document. + * The handle never leaves this call. + */ + internal suspend fun newProposalProperties( + ownerId: ByteArray, + params: DecisionProposalParams, + ): String = sdk.queryGate.op { + val built = mapNativeErrors { + DecisionProposalsNative.build( + sdk.handle, + ownerId.toBase58String(), + params.title, + params.description, + params.url, + params.passPercent, + params.minimumTurnoutPercent, + params.endDateMs, + ) + } + try { + mapNativeErrors { DecisionProposalsNative.propertiesJson(built.documentHandle) } + .returned("propertiesJson") + } finally { + DecisionProposalsNative.documentFree(built.documentHandle) + } + } + + /** + * The properties of [proposalId] with the text [edit] sets: fetches the stored proposal + * (`fetchDocument`), edits a copy (`buildEdit`), reads the copy's properties + * (`propertiesJson`) and frees both documents. The handles never leave this call. + * + * @throws DashSdkError.NotFound when there is no such proposal. + */ + internal suspend fun editedProposalProperties( + proposalId: String, + edit: DecisionProposalEdit, + ): String = sdk.queryGate.op { + val stored = mapNativeErrors { DecisionProposalsNative.fetchDocument(sdk.handle, proposalId) } + if (stored == 0L) throw DashSdkError.NotFound("There is no decision proposal $proposalId") + try { + val (url, removeUrl) = when (val change = edit.url) { + DecisionProposalEdit.UrlChange.Keep -> null to false + is DecisionProposalEdit.UrlChange.Set -> change.url to false + DecisionProposalEdit.UrlChange.Remove -> null to true + } + val edited = mapNativeErrors { + DecisionProposalsNative.buildEdit( + stored, + contractId, + documentTypeName, + edit.title, + edit.description, + url, + removeUrl, + ) + } + try { + mapNativeErrors { DecisionProposalsNative.propertiesJson(edited) } + .returned("propertiesJson") + } finally { + DecisionProposalsNative.documentFree(edited) + } + } finally { + DecisionProposalsNative.documentFree(stored) + } + } +} + +/** + * Creating and editing decision proposals through the platform wallet: the Kotlin + * counterpart of Swift's `ManagedPlatformWallet` decision proposal slice + * (`ManagedPlatformWalletDecisionProposals.swift`). Reach it through + * `PlatformWalletManager.decisionProposals`; callers thread the wallet handle and the + * manager's `signerHandle` into each call, as for [DocumentTransactions]. + * + * Each call sequences native calls, never one stitched JNI export: the SDK builds the + * proposal document (or fetches and edits the stored one) and hands over its properties, + * then the wallet puts them with [DocumentTransactions.create] or + * [DocumentTransactions.replace], under the decision proposals contract and type the FFI + * names. The create transition states the decision proposal fund (0.5 Dash at protocol + * version 15) itself, paid by the owner on top of the fees. + */ +class DecisionProposalTransactions internal constructor( + private val gate: TeardownGate?, + private val documents: DocumentTransactions, +) { + + /** + * Create and broadcast a decision proposal of [ownerId] asking what [params] says, + * signed via [signerHandle] with a key the wallet selects. + * + * @param sdk the SDK of the wallet's network, which builds the proposal. + * @param ownerId the proposer's 32-byte identity id; a wallet identity. + * @return the confirmed document's canonical JSON; its base58 `$id` is the proposal id. + */ + suspend fun create( + sdk: Sdk, + walletHandle: Long, + ownerId: ByteArray, + params: DecisionProposalParams, + signerHandle: Long, + ): String = gate.op { + val proposals = sdk.decisionProposals + val properties = proposals.newProposalProperties(ownerId, params) + documents.create( + walletHandle = walletHandle, + ownerId = ownerId, + contractId = proposals.contractIdBytes, + documentType = proposals.documentTypeName, + propertiesJson = properties, + signerHandle = signerHandle, + // A proposal joins no contest. + maxContestFund = 0L, + ) + } + + /** + * Give the proposal [proposalId] of [ownerId] the text [edit] sets and broadcast the + * replace, signed via [signerHandle] with key [signingKeyId]. The wallet re-fetches the + * proposal and moves it to the same next revision the edit was built at. + * + * @param proposalId the proposal's 32-byte id. + * @return the confirmed document's canonical JSON. + * @throws DashSdkError.NotFound when there is no such proposal. + */ + suspend fun edit( + sdk: Sdk, + walletHandle: Long, + ownerId: ByteArray, + proposalId: ByteArray, + edit: DecisionProposalEdit, + signingKeyId: Int, + signerHandle: Long, + ): String = gate.op { + val proposals = sdk.decisionProposals + val properties = proposals.editedProposalProperties(proposalId.toBase58String(), edit) + documents.replace( + walletHandle = walletHandle, + ownerId = ownerId, + contractId = proposals.contractIdBytes, + documentType = proposals.documentTypeName, + documentId = proposalId, + propertiesJson = properties, + signingKeyId = signingKeyId, + signerHandle = signerHandle, + ) + } +} + +/** + * The value a native call returned. Null is reserved for a thrown error (already raised by + * the JNI layer) or an absence the call can report; for a call that always answers, null is + * a broken native result. + */ +private fun T?.returned(call: String): T = + this ?: throw DashSdkError.InternalError("DecisionProposalsNative.$call returned nothing") diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index e552cdfcdfc..eac0d2bceaf 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -840,6 +840,18 @@ class PlatformWalletManager( val voteCasting: org.dashfoundation.dashsdk.voting.VoteCasting = org.dashfoundation.dashsdk.voting.VoteCasting() + /** + * Decision proposal create and edit (protocol version 15): the decision proposal + * slice of `ManagedPlatformWallet.swift`. Stateless; callers thread the SDK, the + * wallet handle and [signerHandle] into each call. Reads and the masternode vote are + * on `Sdk.decisionProposals`. + */ + val decisionProposals: org.dashfoundation.dashsdk.voting.DecisionProposalTransactions = + org.dashfoundation.dashsdk.voting.DecisionProposalTransactions( + teardownGate, + documentTransactions, + ) + // ── Native manager bundle ───────────────────────────────────────── private val nativeInitialization = initializePlatformWalletNativeManager( diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt new file mode 100644 index 00000000000..8e7872fdf90 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt @@ -0,0 +1,239 @@ +package org.dashfoundation.dashsdk.voting + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Decoding of what the decision proposal natives return, and the vote choice encoding. + * + * Rust writes every value (rs-sdk-ffi's `decision_proposal/json.rs` tests pin the same + * shapes); Kotlin only decodes, so these tests pin the JSON as rs-sdk-ffi writes it, key for + * key. Pure JVM: no native library is loaded. + */ +class DecisionProposalModelsTest { + + private val proposalId = "8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN" + private val ownerId = "CQwyTH5AnQ5YQVGeYhBP8ihLdMKKbS6ydzN2E8wRWMNn" + + private val proposalJson = """ + {"id":"$proposalId","ownerId":"$ownerId","revision":1, + "createdAt":1700000000000,"updatedAt":1700000000000, + "title":"Raise the block size?","description":"Yes doubles it; no keeps it.", + "url":"https://forum.dash.org/t/block-size", + "passPercent":60,"minimumTurnoutPercent":10,"endDate":1701209600000} + """.trimIndent() + + private val votePollJson = """ + {"${'$'}type":"yesNoVotePoll", + "purpose":{"${'$'}type":"decisionProposal","documentId":"$proposalId"}, + "supermajorityNumerator":60,"supermajorityDenominator":100, + "minimumVotingPower":{"${'$'}type":"fractionOfTotal","numerator":10,"denominator":100,"rounding":"up"}} + """.trimIndent() + + @Test + fun shouldDecodeAProposalWithEveryField() { + assertEquals( + DecisionProposal( + id = proposalId, + ownerId = ownerId, + revision = 1, + createdAtMs = 1_700_000_000_000, + updatedAtMs = 1_700_000_000_000, + title = "Raise the block size?", + description = "Yes doubles it; no keeps it.", + url = "https://forum.dash.org/t/block-size", + passPercent = 60, + minimumTurnoutPercent = 10, + endDateMs = 1_701_209_600_000, + ), + DecisionProposal.fromJson(proposalJson), + ) + } + + @Test + fun shouldDecodeAnAbsentUrlAndAbsentSystemFieldsAsNull() { + val proposal = DecisionProposal.fromJson( + """ + {"id":"$proposalId","ownerId":"$ownerId","revision":null,"createdAt":null, + "updatedAt":null,"title":"t","description":"d","url":null, + "passPercent":50,"minimumTurnoutPercent":0,"endDate":1701209600000} + """.trimIndent(), + ) + assertNull(proposal.url) + assertNull(proposal.revision) + assertNull(proposal.createdAtMs) + assertNull(proposal.updatedAtMs) + } + + @Test + fun shouldDecodeAListingInItsOrder() { + val second = proposalJson.replace(proposalId, ownerId) + val listing = DecisionProposal.listFromJson("[$proposalJson,$second]") + assertEquals(listOf(proposalId, ownerId), listing.map { it.id }) + assertEquals(emptyList(), DecisionProposal.listFromJson("[]")) + } + + @Test + fun shouldDecodeAStartedPollWithItsTalliesAndRule() { + val state = YesNoVotePollState.fromJson( + """ + {"votePoll":$votePollJson,"status":"started", + "startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3}, + "yesVotingPower":5,"noVotingPower":1,"abstainVotingPower":4} + """.trimIndent(), + ) + assertEquals( + YesNoVotePollState( + votePoll = YesNoVotePoll( + purpose = YesNoVotePollPurpose.DecisionProposal(proposalId), + supermajorityNumerator = 60, + supermajorityDenominator = 100, + minimumVotingPower = YesNoMinimumVotingPower.FractionOfTotal( + numerator = 10, + denominator = 100, + rounding = VotingPowerRounding.UP, + ), + ), + status = YesNoVotePollStatus.Started( + PlatformBlockInfo(height = 7, coreHeight = 42, timeMs = 7_000, epoch = 3), + ), + yesVotingPower = 5, + noVotingPower = 1, + abstainVotingPower = 4, + ), + state, + ) + assertNull(state.result) + } + + @Test + fun shouldDecodeAFinishedPollWithItsResult() { + val state = YesNoVotePollState.fromJson( + """ + {"votePoll":$votePollJson,"status":"finished", + "result":{"passed":true,"yesVotingPower":30,"noVotingPower":10, + "abstainVotingPower":8,"requiredVotingPower":20, + "startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3}, + "finalizationBlock":{"height":9,"coreHeight":42,"timeMs":9000,"epoch":3}}, + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """.trimIndent(), + ) + val result = YesNoVotePollResult( + passed = true, + yesVotingPower = 30, + noVotingPower = 10, + abstainVotingPower = 8, + requiredVotingPower = 20, + startBlock = PlatformBlockInfo(height = 7, coreHeight = 42, timeMs = 7_000, epoch = 3), + finalizationBlock = PlatformBlockInfo(height = 9, coreHeight = 42, timeMs = 9_000, epoch = 3), + ) + assertEquals(YesNoVotePollStatus.Finished(result), state.status) + assertEquals(result, state.result) + assertEquals(0L, state.yesVotingPower) + assertEquals(0L, state.noVotingPower) + assertEquals(0L, state.abstainVotingPower) + } + + @Test + fun shouldDecodeAnAbsoluteMinimumVotingPower() { + val state = YesNoVotePollState.fromJson( + """ + {"votePoll":${votePollJson.replace( + """{"${'$'}type":"fractionOfTotal","numerator":10,"denominator":100,"rounding":"up"}""", + """{"${'$'}type":"absolute","votingPower":400}""", + )},"status":"started", + "startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3}, + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """.trimIndent(), + ) + assertEquals( + YesNoMinimumVotingPower.Absolute(votingPower = 400), + state.votePoll.minimumVotingPower, + ) + } + + @Test + fun shouldDecodeEveryRoundingName() { + val names = mapOf( + "down" to VotingPowerRounding.DOWN, + "up" to VotingPowerRounding.UP, + "downPlusOne" to VotingPowerRounding.DOWN_PLUS_ONE, + "upMinusOne" to VotingPowerRounding.UP_MINUS_ONE, + ) + for ((name, rounding) in names) { + val state = YesNoVotePollState.fromJson( + """ + {"votePoll":${votePollJson.replace("\"up\"", "\"$name\"")},"status":"started", + "startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3}, + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """.trimIndent(), + ) + val minimum = state.votePoll.minimumVotingPower as YesNoMinimumVotingPower.FractionOfTotal + assertEquals(name, rounding, minimum.rounding) + } + } + + @Test + fun shouldDecodeAMasternodeAnswerForEveryChoice() { + for (choice in YesNoAbstainVoteChoice.entries) { + assertEquals( + MasternodeYesNoVote(choice = choice, timesVoted = 2), + MasternodeYesNoVote.fromJson("""{"choice":"${choice.jsonName}","timesVoted":2}"""), + ) + } + } + + /** The `vote_choice` byte of `dash_sdk_decision_proposal_cast_vote`. */ + @Test + fun shouldEncodeTheVoteChoiceAsTheFfiByte() { + assertEquals(0, YesNoAbstainVoteChoice.YES.discriminant) + assertEquals(1, YesNoAbstainVoteChoice.NO.discriminant) + assertEquals(2, YesNoAbstainVoteChoice.ABSTAIN.discriminant) + assertEquals("yes", YesNoAbstainVoteChoice.YES.jsonName) + assertEquals("no", YesNoAbstainVoteChoice.NO.jsonName) + assertEquals("abstain", YesNoAbstainVoteChoice.ABSTAIN.jsonName) + assertEquals(3, YesNoAbstainVoteChoice.entries.size) + } + + @Test + fun shouldRefuseJsonMissingARequiredField() { + val error = assertThrows(DashSdkError.SerializationError::class.java) { + DecisionProposal.fromJson(proposalJson.replace("\"endDate\"", "\"closesAt\"")) + } + assertTrue(error.message.orEmpty(), error.message.orEmpty().contains("endDate")) + } + + @Test + fun shouldRefuseAFieldOfTheWrongType() { + assertThrows(DashSdkError.SerializationError::class.java) { + DecisionProposal.fromJson(proposalJson.replace("\"passPercent\":60", "\"passPercent\":\"60\"")) + } + } + + @Test + fun shouldRefuseAnUnknownPollStatusOrChoice() { + assertThrows(DashSdkError.SerializationError::class.java) { + YesNoVotePollState.fromJson( + """{"votePoll":$votePollJson,"status":"paused", + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0}""", + ) + } + assertThrows(DashSdkError.SerializationError::class.java) { + MasternodeYesNoVote.fromJson("""{"choice":"maybe","timesVoted":1}""") + } + } + + @Test + fun shouldRefuseTextThatIsNotJson() { + assertThrows(DashSdkError.SerializationError::class.java) { + DecisionProposal.listFromJson("not json") + } + assertThrows(DashSdkError.SerializationError::class.java) { + DecisionProposal.listFromJson(proposalJson) + } + } +} diff --git a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs index 6d969b6a2fc..4a6475b5794 100644 --- a/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs +++ b/packages/rs-sdk-ffi/src/contested_resource/transitions/cast_vote.rs @@ -8,75 +8,27 @@ //! [`PutVote`] entry point. Nothing is stitched together here that the SDK //! does not already expose as a single call. //! -//! # Who can actually cast a vote +//! Turning the voting private key and pro_tx_hash into the signer, the voting +//! key and the voter identity, the pro_tx_hash byte order, and the deferred +//! diagnosis of a refused voting key are shared with the decision proposal +//! vote and documented in [`crate::voting::masternode_vote`]. //! -//! A contested-resource (DPNS) vote is cast by a **masternode** using its -//! masternode *voting key* — an `ECDSA_HASH160` key whose 20-byte `data` is -//! the hash160 of the voting public key, tied to the masternode's -//! `pro_tx_hash` (see `get_voter_identity_key_v0` in rs-drive-abci). The -//! voter identity is derived deterministically as -//! `create_voter_identifier(pro_tx_hash, voting_address)`. -//! -//! This FFI therefore takes the raw 32-byte **voting private key** plus the -//! 32-byte `pro_tx_hash`. From the private key it derives: -//! * a [`SingleKeySigner`] that signs the transition, and -//! * `hash160(pubkey)` — the voting address, which identifies both the voter -//! identity (`create_voter_identifier`) and the key on it. -//! -//! The voting [`IdentityPublicKey`] is **built locally, not fetched**. -//! Platform always assigns a voter identity's voting key id 0: -//! `create_voter_identity_v0` passes 0, and a rotation creates a *different* -//! identity — the identifier includes the voting address — whose key is -//! likewise 0. So the key Platform holds is knowable without a round trip, and -//! `SingleKeySigner::can_sign_with` for `ECDSA_HASH160` recomputes the same -//! `hash160(pubkey)` from the private key, so key and signer agree by -//! construction. -//! -//! # Diagnosis is deferred to the failure path -//! -//! Two things do fail, and Platform reports both as the same opaque -//! "Public key 0 doesn't exist": -//! -//! * no voter identity exists for this `(pro_tx_hash, voting address)` pair, -//! or -//! * after a rotation `update_voter_identity_v0` **disabled** the old -//! identity's keys, so key 0 exists but is unusable. -//! -//! Telling those apart needs the identity, but fetching it before every cast -//! would spend a Platform round trip per (node, contest) on runs that -//! overwhelmingly succeed — a bulk vote of 6 nodes across 10 names is 60 -//! fetches. So the fetch happens only after a broadcast has already failed, -//! where the cost is paid on a path that is already lost. A diagnosis replaces -//! the opaque error; an unrelated failure (network, fees, a closed poll) -//! survives unchanged rather than being recast as a key problem. -//! -//! A regular wallet is **not** a masternode and has no voting key, so a vote -//! broadcast from such a wallet reaches a deterministic *authorization* -//! rejection on the platform side (the voter identity / masternode is not -//! found), which is the expected end state when testing without real -//! masternode credentials. The construction + signing + broadcast path is -//! fully exercised regardless. +//! [`MasternodeVoteTransition`]: dash_sdk::dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition +//! [`PutVote`]: dash_sdk::platform::transition::vote::PutVote +use crate::contested_resource::parse_index_value; use crate::sdk::SDKWrapper; -use crate::types::{FFINetwork, Network, SDKHandle}; +use crate::types::{FFINetwork, SDKHandle}; +use crate::voting::masternode_vote::MasternodeVoter; use crate::{DashSDKResult, FFIError}; -use dash_sdk::dpp::dashcore::hashes::{hash160, Hash}; -use dash_sdk::dpp::dashcore::ProTxHash; use dash_sdk::dpp::platform_value::{Identifier, Value}; -use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use dash_sdk::dpp::voting::vote_polls::VotePoll; use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::Vote; -use dash_sdk::platform::transition::masternode_vote_keys::{ - diagnose_voting_key_failure, is_voting_key_failure, voter_identity_voting_key, VotingKeyProblem, -}; -use dash_sdk::platform::transition::vote::PutVote; -use simple_signer::SingleKeySigner; use std::ffi::{c_char, CStr}; -use zeroize::Zeroizing; /// Resource vote choice discriminant, mirroring /// `ResourceVoteChoice::try_from((i32, Option>))` in rs-dpp: @@ -220,12 +172,12 @@ unsafe fn cast_vote_inner( "contract_id, document_type_name, index_name and index_values_json must be non-null", )); } - if voter_pro_tx_hash.is_null() { - return Err(invalid("voter_pro_tx_hash is null")); - } - if voting_private_key.is_null() { - return Err(invalid("voting_private_key is null")); - } + + // ---- Read the masternode ------------------------------------------------- + // The pro_tx_hash arrives in wire order and the shared reader reverses it to + // the order Platform names masternodes by, for both the voter identity and + // the transition. See `crate::voting::masternode_vote`. + let voter = MasternodeVoter::from_raw(voter_pro_tx_hash, voting_private_key, network)?; // ---- Parse the vote poll strings ---------------------------------------- let contract_id_str = cstr(contract_id, "contract_id")?; @@ -253,7 +205,7 @@ unsafe fn cast_vote_inner( .map_err(|e| invalid(&format!("Failed to parse index_values JSON: {}", e)))?; let index_values: Vec = index_values_array .into_iter() - .map(crate::contested_resource::parse_index_value) + .map(parse_index_value) .collect::, String>>() .map_err(|e| invalid(&e))?; @@ -302,100 +254,11 @@ unsafe fn cast_vote_inner( resource_vote_choice, })); - // ---- pro_tx_hash --------------------------------------------------------- - // Callers pass WIRE order — the orientation `Txid` stores, which is what a - // parsed ProRegTx yields (`reg.txid()`) and what the iOS wallet holds. - // - // Platform identifies masternodes by the OTHER orientation. `ProTxHash` is - // declared `#[hash_newtype(forward)]` while `Txid` is not, so - // `ProTxHash::to_byte_array()` is display order — the reverse of a `Txid`'s - // bytes for the same transaction. `rpc-json`'s `MasternodeListItem` holds - // both conventions side by side (`pro_tx_hash: ProTxHash`, - // `collateral_hash: Txid`), and drive-abci builds the voter identity from - // `masternode.pro_tx_hash.to_byte_array()`. - // - // Feeding wire order to `create_voter_identifier` therefore asks Platform - // for an identity that has never existed, and the vote is rejected as - // having no voter identity — with the real one sitting under the reversed - // hash. Reverse once, here, and use the corrected value for both the - // identifier and the transition so they cannot drift apart. - let pro_tx_hash_slice = std::slice::from_raw_parts(voter_pro_tx_hash, 32); - let mut pro_tx_hash_arr = [0u8; 32]; - pro_tx_hash_arr.copy_from_slice(pro_tx_hash_slice); - pro_tx_hash_arr.reverse(); - let pro_tx_hash = Identifier::new(pro_tx_hash_arr); - - // ---- Derive the masternode voting key + signer -------------------------- - let network: Network = network.into(); - - // Hold the private key in a zeroizing buffer so it does not linger. - let key_slice = std::slice::from_raw_parts(voting_private_key, 32); - let mut key_array = Zeroizing::new([0u8; 32]); - key_array.copy_from_slice(key_slice); - - let signer = SingleKeySigner::new_from_slice(key_array.as_slice(), network) - .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; - - // The masternode voting key is the 20-byte hash160 of the (compressed) - // public key. `MasternodeVoteTransition` calls - // `masternode_voting_key.public_key_hash()` to derive the voter - // identifier, and `SingleKeySigner::can_sign_with` recomputes the same - // hash160 from the private key, so the two agree by construction. - let secret_key = SecretKey::from_secret_bytes(*key_array) - .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; - let public_key = PublicKey::from_secret_key(&secret_key); - let voting_address = hash160::Hash::hash(&public_key.serialize()).to_byte_array(); - // ---- Broadcast, then diagnose only on failure --------------------------- let wrapper = &*(sdk_handle as *const SDKWrapper); - let sdk = &wrapper.sdk; - - // The key Platform holds, built without a lookup — see the rs-sdk helper - // for why the id is knowable. `SingleKeySigner::can_sign_with` recomputes - // the same hash160 from the private key, so key and signer agree by - // construction. - let masternode_voting_key = voter_identity_voting_key(&voting_address); - - let broadcast = wrapper.runtime.block_on(async { - vote.put_to_platform_and_wait_for_response( - pro_tx_hash, - &masternode_voting_key, - sdk, - &signer, - None, - ) - .await - }); - - let Err(broadcast_error) = broadcast else { - return Ok(()); - }; - - // Only a signature failure about the voting key is worth explaining. A - // closed poll, a fee failure or a transport error says nothing about the - // identity, and diagnosing those would let an absent voter identity - // masquerade as their cause — reporting "no voting identity" for a vote - // that actually arrived too late. - if !is_voting_key_failure(&broadcast_error) { - return Err(FFIError::from(broadcast_error)); - } - - // A missing voter identity and a rotated (disabled) key are - // indistinguishable from Platform's side — both surface as - // "Public key 0 doesn't exist". Fetching the identity says which, but only - // here: doing it before every cast would spend a round trip per - // (node, contest) on runs that overwhelmingly succeed. - let problem = wrapper.runtime.block_on(diagnose_voting_key_failure( - sdk, - ProTxHash::from_byte_array(pro_tx_hash_arr), - &voting_address, - )); - - // Still fall back to the original when the identity and key both check out - // — the signature failure was about something else on the key path. - Err(problem - .map(describe_voting_key_problem) - .unwrap_or_else(|| FFIError::from(broadcast_error))) + wrapper + .runtime + .block_on(voter.put_vote(&wrapper.sdk, &vote)) } fn invalid(message: &str) -> FFIError { @@ -408,33 +271,6 @@ unsafe fn cstr<'a>(ptr: *const c_char, field: &str) -> Result<&'a str, FFIError> .map_err(|e| invalid(&format!("Invalid UTF-8 in {}: {}", field, e))) } -/// Render a [`VotingKeyProblem`] as the message a host surfaces. -/// -/// rs-sdk reports the fact; phrasing it is a binding concern, so the sentence -/// lives here rather than in the SDK where every caller would inherit it. -fn describe_voting_key_problem(problem: VotingKeyProblem) -> FFIError { - match problem { - VotingKeyProblem::NoVoterIdentity { - pro_tx_hash, - expected_voter_identity, - } => FFIError::InvalidParameter(format!( - "No voting identity exists on Platform for masternode {} with this voting key \ - (expected voter identity {}). Either the voting key does not match the \ - masternode's registered voting address, or Platform has not created the \ - voter identity yet.", - Identifier::new(pro_tx_hash.to_byte_array()), - expected_voter_identity - )), - VotingKeyProblem::NoUsableVotingKey { voter_identity } => { - FFIError::InvalidParameter(format!( - "Voter identity {} has no enabled ECDSA_HASH160 voting key matching this \ - private key. The masternode's voting key may have been rotated.", - voter_identity - )) - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -553,49 +389,4 @@ mod tests { crate::test_utils::test_utils::destroy_mock_sdk_handle(handle); } } - - // ---- describe_voting_key_problem ---------------------------------------- - // - // Selection, the failure gate and the diagnosis itself now live in rs-sdk - // (`platform::transition::masternode_vote_keys`) and are tested there. What - // remains here is the one thing this layer owns: turning the SDK's typed - // fact into the sentence a host shows. - - use dash_sdk::dpp::dashcore::ProTxHash; - use dash_sdk::platform::transition::masternode_vote_keys::VotingKeyProblem; - - #[test] - fn missing_identity_message_names_both_identifiers() { - let pro_tx = ProTxHash::from_byte_array([1u8; 32]); - let expected = Identifier::new([3u8; 32]); - let msg = format!( - "{:?}", - describe_voting_key_problem(VotingKeyProblem::NoVoterIdentity { - pro_tx_hash: pro_tx, - expected_voter_identity: expected, - }) - ); - - assert!(msg.contains(&format!("{}", Identifier::new(pro_tx.to_byte_array())))); - assert!(msg.contains(&format!("{}", expected))); - // The message crosses the FFI boundary verbatim and is shown to users, - // so it must read as prose — an earlier version rendered with blank - // gaps from an unescaped multi-line literal. - assert!(!msg.contains(" "), "message has whitespace runs: {msg}"); - } - - #[test] - fn rotated_key_message_names_the_identity_and_the_cause() { - let voter = Identifier::new([5u8; 32]); - let msg = format!( - "{:?}", - describe_voting_key_problem(VotingKeyProblem::NoUsableVotingKey { - voter_identity: voter, - }) - ); - - assert!(msg.contains(&format!("{}", voter))); - assert!(msg.contains("rotated")); - assert!(!msg.contains(" "), "message has whitespace runs: {msg}"); - } } diff --git a/packages/rs-sdk-ffi/src/decision_proposal/json.rs b/packages/rs-sdk-ffi/src/decision_proposal/json.rs new file mode 100644 index 00000000000..bf48e1a5ebf --- /dev/null +++ b/packages/rs-sdk-ffi/src/decision_proposal/json.rs @@ -0,0 +1,411 @@ +//! The JSON the decision proposal functions return, one shape per kind, written from the +//! rs-sdk types. Field names follow the wasm bindings (`packages/wasm-sdk/src/decision_proposals.rs` +//! and `packages/wasm-sdk/src/queries/voting/yes_no.rs`). + +use crate::FFIError; +use dash_sdk::dpp::block::block_info::BlockInfo; +use dash_sdk::dpp::document::{Document, DocumentV0Getters}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::serialization::JsonConvertible; +use dash_sdk::dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dash_sdk::dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; +use dash_sdk::dpp::voting::vote_polls::VotePoll; +use dash_sdk::platform::decision_proposals::DecisionProposal; +use dash_sdk::platform::yes_no_vote_polls::{MasternodeYesNoVote, YesNoVotePollState}; +use drive_proof_verifier::types::Documents; +use serde::Serialize; +use serde_json::{json, Value as JsonValue}; + +/// A proposal as every decision proposal read returns it. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProposalJson<'a> { + /// The proposal's id, base58. + id: String, + /// The proposer, base58. + owner_id: String, + revision: Option, + /// Milliseconds since the Unix epoch. + created_at: Option, + /// Milliseconds since the Unix epoch. + updated_at: Option, + title: &'a str, + description: &'a str, + url: Option<&'a str>, + pass_percent: u8, + minimum_turnout_percent: u8, + /// Milliseconds since the Unix epoch. + end_date: u64, +} + +/// `document`, a `proposal` document, as the proposal JSON object. +pub(super) fn proposal_json(document: Document) -> Result { + let proposal = DecisionProposal::from_document(document)?; + let document = &proposal.document; + Ok(serde_json::to_value(ProposalJson { + id: document.id().to_string(Encoding::Base58), + owner_id: document.owner_id().to_string(Encoding::Base58), + revision: document.revision(), + created_at: document.created_at(), + updated_at: document.updated_at(), + title: &proposal.title, + description: &proposal.description, + url: proposal.url.as_deref(), + pass_percent: proposal.pass_percent, + minimum_turnout_percent: proposal.minimum_turnout_percent, + end_date: proposal.end_date, + })?) +} + +/// One page of a listing as a JSON array of proposals, in the listing's order. +pub(super) fn proposals_json(documents: Documents) -> Result { + documents + .into_values() + .flatten() + .map(proposal_json) + .collect::, _>>() + .map(JsonValue::Array) +} + +/// The properties of `document` as the JSON object the platform wallet's document create and +/// replace take (`properties_json`). +pub(super) fn properties_json(document: &Document) -> Result { + Ok(serde_json::to_value(document.properties())?) +} + +fn block_json(block: &BlockInfo) -> JsonValue { + json!({ + "height": block.height, + "coreHeight": block.core_height, + "timeMs": block.time_ms, + "epoch": block.epoch.index, + }) +} + +/// The state of an opened poll; `None` for a poll never opened. +pub(super) fn vote_poll_state_json( + state: Option, +) -> Result, FFIError> { + let Some(state) = state else { + return Ok(None); + }; + let Some(stored_info) = state.stored_info else { + return Ok(None); + }; + let vote_poll = VotePoll::YesNoVotePoll(stored_info.vote_poll().clone()) + .to_json() + .map_err(|e| FFIError::InternalError(format!("Failed to write the vote poll: {e}")))?; + let mut object = json!({ + "votePoll": vote_poll, + "yesVotingPower": state.yes_voting_power, + "noVotingPower": state.no_voting_power, + "abstainVotingPower": state.abstain_voting_power, + }); + match stored_info.status() { + YesNoVotePollStatus::Started(start_block) => { + object["status"] = json!("started"); + object["startBlock"] = block_json(start_block); + } + YesNoVotePollStatus::Finished(result) => { + object["status"] = json!("finished"); + object["result"] = json!({ + "passed": result.passed, + "yesVotingPower": result.yes_voting_power, + "noVotingPower": result.no_voting_power, + "abstainVotingPower": result.abstain_voting_power, + "requiredVotingPower": result.required_voting_power, + "startBlock": block_json(&result.start_block), + "finalizationBlock": block_json(&result.finalization_block), + }); + } + } + Ok(Some(object)) +} + +/// The name a vote choice is written with: `"yes"`, `"no"` or `"abstain"`. +pub(super) fn vote_choice_name(vote_choice: YesNoAbstainVoteChoice) -> &'static str { + match vote_choice { + YesNoAbstainVoteChoice::Yes => "yes", + YesNoAbstainVoteChoice::No => "no", + YesNoAbstainVoteChoice::Abstain => "abstain", + } +} + +/// A masternode's answer; `None` when it has not voted on the poll. +pub(super) fn masternode_vote_json(vote: Option) -> Option { + vote.map(|vote| { + json!({ + "choice": vote_choice_name(vote.vote_choice), + "timesVoted": vote.times_voted, + }) + }) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use dash_sdk::dpp::block::epoch::Epoch; + use dash_sdk::dpp::data_contract::DataContract; + use dash_sdk::dpp::decision_proposal::properties; + use dash_sdk::dpp::document::DocumentV0Setters; + use dash_sdk::dpp::platform_value::{Bytes32, Identifier}; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::v0::YesNoVotePollStoredInfoV0; + use dash_sdk::dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStoredInfo; + use dash_sdk::dpp::voting::vote_polls::yes_no_vote_poll::{ + VotingPowerRounding, YesNoMinimumVotingPower, YesNoVotePoll, YesNoVotePollPurpose, + }; + use dash_sdk::platform::decision_proposals::{ + build_decision_proposal_document, DecisionProposalInput, + }; + + pub(in crate::decision_proposal) const CREATED_AT: u64 = 1_700_000_000_000; + pub(in crate::decision_proposal) const END_DATE: u64 = CREATED_AT + 14 * 86_400_000; + + pub(in crate::decision_proposal) fn proposals_contract() -> DataContract { + load_system_data_contract( + SystemDataContract::DecisionProposals, + PlatformVersion::latest(), + ) + .expect("the decision proposals contract loads at the latest version") + } + + pub(in crate::decision_proposal) fn input() -> DecisionProposalInput { + DecisionProposalInput { + title: "Raise the block size?".to_string(), + description: "Yes doubles it; no keeps it.".to_string(), + url: Some("https://forum.dash.org/t/block-size".to_string()), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date: END_DATE, + } + } + + /// A proposal of identity `[3; 32]` built by rs-sdk, as Platform stores it. + pub(in crate::decision_proposal) fn stored_proposal(input: &DecisionProposalInput) -> Document { + let mut document = build_decision_proposal_document( + &proposals_contract(), + Identifier::from([3; 32]), + input, + Bytes32::new([9; 32]), + ) + .expect("builds") + .document; + document.set_created_at(Some(CREATED_AT)); + document.set_updated_at(Some(CREATED_AT)); + document + } + + #[test] + fn should_write_a_proposal_with_every_field() { + let document = stored_proposal(&input()); + let id = document.id(); + assert_eq!( + proposal_json(document).expect("a proposal"), + json!({ + "id": id.to_string(Encoding::Base58), + "ownerId": Identifier::from([3; 32]).to_string(Encoding::Base58), + "revision": 1, + "createdAt": CREATED_AT, + "updatedAt": CREATED_AT, + "title": "Raise the block size?", + "description": "Yes doubles it; no keeps it.", + "url": "https://forum.dash.org/t/block-size", + "passPercent": 60, + "minimumTurnoutPercent": 10, + "endDate": END_DATE, + }) + ); + } + + #[test] + fn should_write_an_absent_url_as_null() { + let document = stored_proposal(&DecisionProposalInput { + url: None, + ..input() + }); + assert_eq!( + proposal_json(document).expect("a proposal")["url"], + JsonValue::Null + ); + } + + #[test] + fn should_refuse_a_document_that_is_not_a_proposal() { + let mut document = stored_proposal(&input()); + document.remove(properties::TITLE); + assert!(proposal_json(document).is_err()); + } + + #[test] + fn should_write_a_listing_in_its_order_without_absent_entries() { + let first = stored_proposal(&input()); + let mut second = stored_proposal(&input()); + second.set_id(Identifier::from([1; 32])); + let documents: Documents = [ + (first.id(), Some(first.clone())), + (Identifier::from([2; 32]), None), + (second.id(), Some(second.clone())), + ] + .into_iter() + .collect(); + let listing = proposals_json(documents).expect("proposals"); + let ids: Vec<&str> = listing + .as_array() + .expect("an array") + .iter() + .map(|proposal| proposal["id"].as_str().expect("an id")) + .collect(); + assert_eq!( + ids, + [ + first.id().to_string(Encoding::Base58), + second.id().to_string(Encoding::Base58) + ] + ); + assert_eq!( + proposals_json(Documents::default()).expect("empty"), + json!([]) + ); + } + + /// The properties are the document's own, which the wallet's create takes back. + #[test] + fn should_write_the_properties_a_create_takes() { + let document = stored_proposal(&input()); + assert_eq!( + properties_json(&document).expect("properties"), + json!({ + "title": "Raise the block size?", + "description": "Yes doubles it; no keeps it.", + "url": "https://forum.dash.org/t/block-size", + "passPercent": 60, + "minimumTurnoutPercent": 10, + "endDate": END_DATE, + }) + ); + } + + fn poll() -> YesNoVotePoll { + YesNoVotePoll { + purpose: YesNoVotePollPurpose::DecisionProposal { + document_id: Identifier::new([0xc1; 32]), + }, + supermajority_numerator: 60, + supermajority_denominator: 100, + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 10, + denominator: 100, + rounding: VotingPowerRounding::Up, + }, + } + } + + fn poll_json() -> JsonValue { + json!({ + "$type": "yesNoVotePoll", + "purpose": { + "$type": "decisionProposal", + "documentId": Identifier::new([0xc1; 32]).to_string(Encoding::Base58), + }, + "supermajorityNumerator": 60, + "supermajorityDenominator": 100, + "minimumVotingPower": { + "$type": "fractionOfTotal", + "numerator": 10, + "denominator": 100, + "rounding": "up", + }, + }) + } + + fn block(height: u64) -> BlockInfo { + BlockInfo { + time_ms: 1_000 * height, + height, + core_height: 42, + epoch: Epoch::new(3).expect("an epoch"), + } + } + + #[test] + fn should_write_a_started_poll_with_its_tallies() { + let state = YesNoVotePollState { + stored_info: Some(YesNoVotePollStoredInfoV0::new(poll(), block(7)).into()), + yes_voting_power: 5, + no_voting_power: 1, + abstain_voting_power: 4, + }; + assert_eq!( + vote_poll_state_json(Some(state)).expect("writes"), + Some(json!({ + "votePoll": poll_json(), + "status": "started", + "startBlock": {"height": 7, "coreHeight": 42, "timeMs": 7_000, "epoch": 3}, + "yesVotingPower": 5, + "noVotingPower": 1, + "abstainVotingPower": 4, + })) + ); + } + + #[test] + fn should_write_a_finished_poll_with_its_result() { + let mut stored_info: YesNoVotePollStoredInfo = + YesNoVotePollStoredInfoV0::new(poll(), block(7)).into(); + stored_info + .finalize_vote_poll(30, 10, 8, 20, true, block(9)) + .expect("finalizes"); + let state = YesNoVotePollState { + stored_info: Some(stored_info), + ..Default::default() + }; + assert_eq!( + vote_poll_state_json(Some(state)).expect("writes"), + Some(json!({ + "votePoll": poll_json(), + "status": "finished", + "result": { + "passed": true, + "yesVotingPower": 30, + "noVotingPower": 10, + "abstainVotingPower": 8, + "requiredVotingPower": 20, + "startBlock": {"height": 7, "coreHeight": 42, "timeMs": 7_000, "epoch": 3}, + "finalizationBlock": {"height": 9, "coreHeight": 42, "timeMs": 9_000, "epoch": 3}, + }, + "yesVotingPower": 0, + "noVotingPower": 0, + "abstainVotingPower": 0, + })) + ); + } + + #[test] + fn should_write_a_poll_never_opened_as_absent() { + assert_eq!(vote_poll_state_json(None).expect("writes"), None); + assert_eq!( + vote_poll_state_json(Some(YesNoVotePollState::default())).expect("writes"), + None + ); + } + + #[test] + fn should_write_a_masternode_answer() { + for (vote_choice, name) in [ + (YesNoAbstainVoteChoice::Yes, "yes"), + (YesNoAbstainVoteChoice::No, "no"), + (YesNoAbstainVoteChoice::Abstain, "abstain"), + ] { + assert_eq!( + masternode_vote_json(Some(MasternodeYesNoVote { + vote_choice, + times_voted: 2, + })), + Some(json!({"choice": name, "timesVoted": 2})) + ); + } + assert_eq!(masternode_vote_json(None), None); + } +} diff --git a/packages/rs-sdk-ffi/src/decision_proposal/mod.rs b/packages/rs-sdk-ffi/src/decision_proposal/mod.rs new file mode 100644 index 00000000000..53cfe81925f --- /dev/null +++ b/packages/rs-sdk-ffi/src/decision_proposal/mod.rs @@ -0,0 +1,204 @@ +//! Decision proposals (protocol version 15): yes/no questions any identity puts to the +//! masternodes, as `proposal` documents of the decision proposals system contract. Thin +//! bindings over `dash_sdk::platform::decision_proposals` and +//! `dash_sdk::platform::yes_no_vote_polls`: rs-sdk decides everything (the proposal's +//! properties, the decision proposal fund the create states, which documents are proposals, +//! refusing an edit that changes nothing, the next revision, the poll's id); these functions +//! read C arguments, call it, and write the result. +//! +//! # Creating a proposal +//! +//! [`dash_sdk_decision_proposal_build`] builds the proposal document and its entropy. Then +//! either: +//! * put it with `dash_sdk_document_put_to_platform_and_wait`, passing the document handle, +//! the entropy, [`dash_sdk_decision_proposal_contract_id`] and +//! [`dash_sdk_decision_proposal_document_type_name`]; or +//! * pass [`dash_sdk_decision_proposal_properties_json`] of the document to the platform +//! wallet's `platform_wallet_create_document_with_signer`, with +//! [`dash_sdk_decision_proposal_contract_id_bytes`] and the document type name. +//! +//! Either way the create states the decision proposal fund (0.5 Dash) itself, on top of the +//! fees. The proposal's id is final once the put returns the stored document. +//! +//! # Editing a proposal's text +//! +//! [`dash_sdk_decision_proposal_fetch_document`] reads the stored proposal as a document +//! handle, and [`dash_sdk_decision_proposal_build_edit`] gives a copy of it the new text and +//! its next revision. Then either: +//! * put the edited document with `dash_sdk_document_put_to_platform_and_wait` (its revision +//! is above 1, so it is put as a replace; the entropy argument is not read for a replace, +//! pass any 32 bytes). Never `dash_sdk_document_replace_on_platform_and_wait`, which moves +//! the revision once more and is refused; or +//! * pass [`dash_sdk_decision_proposal_properties_json`] of the edited document to the +//! platform wallet's `platform_wallet_document_replace` with the proposal's id. The wallet +//! fetches the stored proposal, sets these properties and moves to the same next revision. +//! +//! # Reading +//! +//! The reads return JSON, null when what they read is absent. A proposal is always the same +//! object (see [`dash_sdk_decision_proposal_fetch`]); the listings return arrays of it. +//! +//! # Voting +//! +//! [`dash_sdk_decision_proposal_cast_vote`] answers a proposal for a masternode from its voting +//! private key and pro_tx_hash, exactly as `dash_sdk_contested_resource_cast_vote` votes on a +//! contest (see `crate::voting::masternode_vote`). + +mod json; +mod queries; +mod transitions; + +pub use queries::*; +pub use transitions::*; + +use crate::sdk::SDKWrapper; +use crate::types::SDKHandle; +use crate::{DashSDKResult, FFIError}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::prelude::Identifier; +use std::ffi::{c_char, CStr, CString}; + +/// The SDK behind `sdk_handle`. +/// +/// # Safety +/// `sdk_handle` must be null or point to an initialized `SDKHandle` that outlives `'a`. +unsafe fn sdk_wrapper<'a>(sdk_handle: *const SDKHandle) -> Result<&'a SDKWrapper, FFIError> { + if sdk_handle.is_null() { + return Err(FFIError::InvalidParameter("SDK handle is null".to_string())); + } + Ok(&*(sdk_handle as *const SDKWrapper)) +} + +/// The UTF-8 string `field` points to. +/// +/// # Safety +/// `ptr` must be null or point to a NUL-terminated string that outlives `'a`. +unsafe fn required_str<'a>(ptr: *const c_char, field: &str) -> Result<&'a str, FFIError> { + if ptr.is_null() { + return Err(FFIError::InvalidParameter(format!("{field} is null"))); + } + optional_str(ptr, field).map(|value| value.unwrap_or_default()) +} + +/// The UTF-8 string `field` points to, `None` when it is null. +/// +/// # Safety +/// `ptr` must be null or point to a NUL-terminated string that outlives `'a`. +unsafe fn optional_str<'a>(ptr: *const c_char, field: &str) -> Result, FFIError> { + if ptr.is_null() { + return Ok(None); + } + CStr::from_ptr(ptr) + .to_str() + .map(Some) + .map_err(|e| FFIError::InvalidParameter(format!("{field} is not UTF-8: {e}"))) +} + +/// The base58 identifier `field` points to. +/// +/// # Safety +/// `ptr` must be null or point to a NUL-terminated string. +unsafe fn required_identifier(ptr: *const c_char, field: &str) -> Result { + parse_identifier(required_str(ptr, field)?, field) +} + +/// The base58 identifier `field` points to, `None` when it is null. +/// +/// # Safety +/// `ptr` must be null or point to a NUL-terminated string. +unsafe fn optional_identifier( + ptr: *const c_char, + field: &str, +) -> Result, FFIError> { + optional_str(ptr, field)? + .map(|value| parse_identifier(value, field)) + .transpose() +} + +fn parse_identifier(value: &str, field: &str) -> Result { + Identifier::from_string(value, Encoding::Base58) + .map_err(|e| FFIError::InvalidParameter(format!("{field} is not a base58 identifier: {e}"))) +} + +/// A result holding `string` as an owned C string (freed with `dash_sdk_string_free`), or no +/// data for `None`. +fn string_result(string: Result, FFIError>) -> DashSDKResult { + match string.and_then(|string| string.map(CString::new).transpose().map_err(Into::into)) { + Ok(Some(string)) => DashSDKResult::success_string(string.into_raw()), + Ok(None) => DashSDKResult::success(std::ptr::null_mut()), + Err(error) => DashSDKResult::error(error.into()), + } +} + +/// A result holding `json` written as a string, or no data for `None`. +fn json_result(json: Result, FFIError>) -> DashSDKResult { + string_result(json.and_then(|json| { + json.map(|json| serde_json::to_string(&json)) + .transpose() + .map_err(Into::into) + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + dash_sdk_error_free, dash_sdk_string_free, DashSDKErrorCode, DashSDKResultDataType, + }; + + #[test] + fn should_read_a_base58_identifier_and_refuse_anything_else() { + let id = Identifier::new([7; 32]); + let base58 = CString::new(id.to_string(Encoding::Base58)).expect("no NUL"); + let not_base58 = CString::new("0OIl").expect("no NUL"); + let too_short = CString::new("3mJr7AoUXx2Wqd").expect("no NUL"); + unsafe { + assert_eq!( + required_identifier(base58.as_ptr(), "id").expect("reads"), + id + ); + assert_eq!( + optional_identifier(base58.as_ptr(), "id").expect("reads"), + Some(id) + ); + assert_eq!( + optional_identifier(std::ptr::null(), "id").expect("reads"), + None + ); + for refused in [ + required_identifier(std::ptr::null(), "id"), + required_identifier(not_base58.as_ptr(), "id"), + required_identifier(too_short.as_ptr(), "id"), + ] { + assert!( + matches!(refused, Err(FFIError::InvalidParameter(_))), + "{refused:?}" + ); + } + } + } + + #[test] + fn should_return_json_as_an_owned_string_and_absence_as_no_data() { + let result = json_result(Ok(Some(serde_json::json!({"a": 1})))); + assert!(result.error.is_null()); + assert_eq!(result.data_type, DashSDKResultDataType::String); + unsafe { + let string = CStr::from_ptr(result.data as *const c_char); + assert_eq!(string.to_str().expect("UTF-8"), r#"{"a":1}"#); + dash_sdk_string_free(result.data as *mut c_char); + } + + let absent = json_result(Ok(None)); + assert!(absent.error.is_null()); + assert!(absent.data.is_null()); + assert_eq!(absent.data_type, DashSDKResultDataType::NoData); + + let failed = json_result(Err(FFIError::InvalidParameter("bad".to_string()))); + assert!(failed.data.is_null()); + unsafe { + assert_eq!((*failed.error).code, DashSDKErrorCode::InvalidParameter); + dash_sdk_error_free(failed.error); + } + } +} diff --git a/packages/rs-sdk-ffi/src/decision_proposal/queries.rs b/packages/rs-sdk-ffi/src/decision_proposal/queries.rs new file mode 100644 index 00000000000..d1506c8cbae --- /dev/null +++ b/packages/rs-sdk-ffi/src/decision_proposal/queries.rs @@ -0,0 +1,493 @@ +//! Decision proposal reads: a proposal by id, the three listings, and the proposal's yes/no +//! poll (its id, its state and a masternode's answer on it). Each returns JSON written by +//! [`super::json`], or no data when what it reads is absent. + +use super::json::{masternode_vote_json, proposal_json, proposals_json, vote_poll_state_json}; +use super::{json_result, optional_identifier, required_identifier, sdk_wrapper, string_result}; +use crate::types::SDKHandle; +use crate::voting::masternode_vote::pro_tx_hash_from_wire_order; +use crate::{DashSDKResult, FFIError}; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_polls::yes_no_vote_poll::YesNoVotePollPurpose; +use dash_sdk::platform::decision_proposals::DecisionProposalEndDates; +use dash_sdk::platform::documents::DocumentsPage; +use dash_sdk::platform::yes_no_vote_polls::{ + MasternodeYesNoVote, MasternodeYesNoVoteQuery, YesNoVotePollState, YesNoVotePollStateQuery, +}; +use dash_sdk::platform::Fetch; +use serde_json::Value as JsonValue; +use std::ffi::c_char; + +/// The page a listing asks for: at most `limit` proposals (0 is Platform's cap of 100), +/// after the proposal `start_after` (null for the first page). +/// +/// # Safety +/// `start_after` must be null or point to a NUL-terminated string. +unsafe fn page(limit: u32, start_after: *const c_char) -> Result { + Ok(DocumentsPage { + limit: (limit > 0).then_some(limit), + start_after: optional_identifier(start_after, "start_after")?, + }) +} + +/// Fetches a decision proposal by id. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `proposal_id` - The proposal's id, base58. +/// +/// # Returns +/// The proposal as a JSON object, or no data when there is none: +/// +/// ```json +/// {"id": "", "ownerId": "", "revision": 1, +/// "createdAt": 1700000000000, "updatedAt": 1700000000000, +/// "title": "Raise the block size?", "description": "Yes doubles it; no keeps it.", +/// "url": "https://forum.dash.org/t/block-size", +/// "passPercent": 60, "minimumTurnoutPercent": 10, "endDate": 1701209600000} +/// ``` +/// +/// `url` is null when the proposal has none; `revision`, `createdAt` and `updatedAt` are +/// null when the document does not carry them. Times are milliseconds since the Unix epoch. +/// The proposal passes when the yes voting power is strictly more than `passPercent` percent +/// of yes plus no, and yes plus no reaches `minimumTurnoutPercent` percent of the network's +/// voting power when it closes, rounded up. Every listing returns proposals in this shape. +/// Free the string with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `proposal_id` must be a valid pointer to a NUL-terminated string for the duration of the +/// call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_fetch( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> DashSDKResult { + json_result(fetch_inner(sdk_handle, proposal_id)) +} + +unsafe fn fetch_inner( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let proposal_id = required_identifier(proposal_id, "proposal_id")?; + wrapper + .runtime + .block_on(wrapper.sdk.fetch_decision_proposal(proposal_id))? + .map(proposal_json) + .transpose() +} + +/// Lists one page of the decision proposals whose end date is within the bounds, closing +/// soonest first (by end date, then by creation time). +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `end_date_from_ms` - The earliest end date listed, included, in milliseconds since the +/// Unix epoch; 0 for no lower bound. The current time lists the open proposals. +/// * `end_date_to_ms` - The end date the listing stops before (left out); 0 for no upper +/// bound. +/// * `limit` - The most proposals to return, at most 100; 0 for 100. +/// * `start_after` - The id (base58) of the last proposal of the previous page, to continue +/// after it; null for the first page. +/// +/// # Returns +/// A JSON array of proposals in the shape `dash_sdk_decision_proposal_fetch` returns, `[]` +/// when there is none. A page shorter than the limit is the last. Free the string with +/// `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `start_after` must be null or a valid pointer to a NUL-terminated string for the duration +/// of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_list_by_end_date( + sdk_handle: *const SDKHandle, + end_date_from_ms: u64, + end_date_to_ms: u64, + limit: u32, + start_after: *const c_char, +) -> DashSDKResult { + json_result(list_by_end_date_inner( + sdk_handle, + end_date_from_ms, + end_date_to_ms, + limit, + start_after, + )) +} + +unsafe fn list_by_end_date_inner( + sdk_handle: *const SDKHandle, + end_date_from_ms: u64, + end_date_to_ms: u64, + limit: u32, + start_after: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let end_dates = DecisionProposalEndDates { + from: (end_date_from_ms > 0).then_some(end_date_from_ms), + to: (end_date_to_ms > 0).then_some(end_date_to_ms), + }; + let page = page(limit, start_after)?; + let documents = wrapper.runtime.block_on( + wrapper + .sdk + .fetch_decision_proposals_by_end_date(end_dates, page), + )?; + proposals_json(documents).map(Some) +} + +/// Lists one page of an identity's decision proposals, in the order it filed them. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `owner_id` - The proposer's identity id, base58. +/// * `limit` - The most proposals to return, at most 100; 0 for 100. +/// * `start_after` - The id (base58) of the last proposal of the previous page; null for the +/// first page. +/// +/// # Returns +/// A JSON array of proposals in the shape `dash_sdk_decision_proposal_fetch` returns, `[]` +/// when there is none. Free the string with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `owner_id` must be a valid pointer to a NUL-terminated string, and `start_after` null or +/// one, for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_list_by_owner( + sdk_handle: *const SDKHandle, + owner_id: *const c_char, + limit: u32, + start_after: *const c_char, +) -> DashSDKResult { + json_result(list_by_owner_inner( + sdk_handle, + owner_id, + limit, + start_after, + )) +} + +unsafe fn list_by_owner_inner( + sdk_handle: *const SDKHandle, + owner_id: *const c_char, + limit: u32, + start_after: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let owner_id = required_identifier(owner_id, "owner_id")?; + let page = page(limit, start_after)?; + let documents = wrapper.runtime.block_on( + wrapper + .sdk + .fetch_decision_proposals_by_owner(owner_id, page), + )?; + proposals_json(documents).map(Some) +} + +/// Lists one page of every decision proposal, newest first. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `limit` - The most proposals to return, at most 100; 0 for 100. +/// * `start_after` - The id (base58) of the last proposal of the previous page; null for the +/// first page. +/// +/// # Returns +/// A JSON array of proposals in the shape `dash_sdk_decision_proposal_fetch` returns, `[]` +/// when there is none. Free the string with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `start_after` must be null or a valid pointer to a NUL-terminated string for the duration +/// of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_list_by_created_at( + sdk_handle: *const SDKHandle, + limit: u32, + start_after: *const c_char, +) -> DashSDKResult { + json_result(list_by_created_at_inner(sdk_handle, limit, start_after)) +} + +unsafe fn list_by_created_at_inner( + sdk_handle: *const SDKHandle, + limit: u32, + start_after: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let page = page(limit, start_after)?; + let documents = wrapper + .runtime + .block_on(wrapper.sdk.fetch_decision_proposals_by_created_at(page))?; + proposals_json(documents).map(Some) +} + +/// The id of the yes/no poll a decision proposal opens: the id +/// `dash_sdk_voting_get_vote_polls_by_end_date` lists it under and votes name it by. Derived +/// with the protocol version the SDK speaks; a network below protocol version 15 has no polls, +/// which is an error. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `proposal_id` - The proposal's id, base58. +/// +/// # Returns +/// The poll's id as a base58 string (not JSON). Free it with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `proposal_id` must be a valid pointer to a NUL-terminated string for the duration of the +/// call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_get_vote_poll_id( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> DashSDKResult { + string_result(get_vote_poll_id_inner(sdk_handle, proposal_id)) +} + +unsafe fn get_vote_poll_id_inner( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let proposal_id = required_identifier(proposal_id, "proposal_id")?; + let vote_poll_id = wrapper + .runtime + .block_on(wrapper.sdk.decision_proposal_vote_poll_id(proposal_id))?; + Ok(Some(vote_poll_id.to_string(Encoding::Base58))) +} + +/// The status and tallies of a decision proposal's yes/no poll. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `proposal_id` - The proposal's id, base58. +/// +/// # Returns +/// A JSON object, or no data when the poll was never opened (no such proposal was created). +/// While the poll is open: +/// +/// ```json +/// {"votePoll": {"$type": "yesNoVotePoll", +/// "purpose": {"$type": "decisionProposal", "documentId": ""}, +/// "supermajorityNumerator": 60, "supermajorityDenominator": 100, +/// "minimumVotingPower": {"$type": "fractionOfTotal", "numerator": 10, +/// "denominator": 100, "rounding": "up"}}, +/// "status": "started", +/// "startBlock": {"height": 7, "coreHeight": 42, "timeMs": 7000, "epoch": 3}, +/// "yesVotingPower": 5, "noVotingPower": 1, "abstainVotingPower": 4} +/// ``` +/// +/// Once it finished, `status` is `"finished"`, `startBlock` is absent and `result` holds the +/// record: `{"passed": true, "yesVotingPower": 30, "noVotingPower": 10, +/// "abstainVotingPower": 8, "requiredVotingPower": 20, "startBlock": {...}, +/// "finalizationBlock": {...}}`; the top-level tallies are then 0, as the votes are removed. +/// `votePoll` is the poll as it was opened, rule included (`minimumVotingPower` may also be +/// `{"$type": "absolute", "votingPower": 400}`). Voting powers count a masternode 1 and an +/// evonode 4. Free the string with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `proposal_id` must be a valid pointer to a NUL-terminated string for the duration of the +/// call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_get_vote_poll_state( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> DashSDKResult { + json_result(get_vote_poll_state_inner(sdk_handle, proposal_id)) +} + +unsafe fn get_vote_poll_state_inner( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let document_id = required_identifier(proposal_id, "proposal_id")?; + let query = + YesNoVotePollStateQuery::new(YesNoVotePollPurpose::DecisionProposal { document_id }); + let state = wrapper + .runtime + .block_on(YesNoVotePollState::fetch(&wrapper.sdk, query))?; + vote_poll_state_json(state) +} + +/// A masternode's answer on a decision proposal and how many times it has voted on it. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `voter_pro_tx_hash` - Pointer to the masternode's 32-byte pro_tx_hash in wire order, the +/// same bytes `dash_sdk_decision_proposal_cast_vote` takes (the reverse of the hex Core +/// displays). They are reversed here, as for the vote. +/// * `proposal_id` - The proposal's id, base58. +/// +/// # Returns +/// `{"choice": "yes" | "no" | "abstain", "timesVoted": 2}`, or no data when the masternode +/// has not voted on the proposal, and once the poll finished, which removes its votes. +/// `timesVoted` counts the first vote. Free the string with `dash_sdk_string_free`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `voter_pro_tx_hash` must point to 32 readable bytes and `proposal_id` to a +/// NUL-terminated string for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_get_masternode_vote( + sdk_handle: *const SDKHandle, + voter_pro_tx_hash: *const u8, + proposal_id: *const c_char, +) -> DashSDKResult { + json_result(get_masternode_vote_inner( + sdk_handle, + voter_pro_tx_hash, + proposal_id, + )) +} + +unsafe fn get_masternode_vote_inner( + sdk_handle: *const SDKHandle, + voter_pro_tx_hash: *const u8, + proposal_id: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let pro_tx_hash = pro_tx_hash_from_wire_order(voter_pro_tx_hash)?; + let document_id = required_identifier(proposal_id, "proposal_id")?; + let query = MasternodeYesNoVoteQuery::new( + pro_tx_hash, + YesNoVotePollPurpose::DecisionProposal { document_id }, + ); + let vote = wrapper + .runtime + .block_on(MasternodeYesNoVote::fetch(&wrapper.sdk, query))?; + Ok(masternode_vote_json(vote)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; + use crate::{dash_sdk_error_free, DashSDKErrorCode}; + use dash_sdk::dpp::platform_value::Identifier; + use std::ffi::CString; + + /// `result` failed with `InvalidParameter`; its error is freed. + unsafe fn assert_invalid_parameter(result: DashSDKResult) { + assert!(result.data.is_null()); + assert!(!result.error.is_null()); + let code = (*result.error).code; + dash_sdk_error_free(result.error); + assert_eq!(code, DashSDKErrorCode::InvalidParameter); + } + + fn base58(id: Identifier) -> CString { + CString::new(id.to_string(Encoding::Base58)).expect("no NUL") + } + + /// Every read refuses a null SDK handle, a null or non-base58 id and a null pro_tx_hash + /// before anything reaches the network. + #[test] + fn should_refuse_missing_or_malformed_arguments_before_any_request() { + let handle = create_mock_sdk_handle(); + let id = base58(Identifier::new([4; 32])); + let not_base58 = CString::new("not base58: 0OIl").expect("no NUL"); + let pro_tx_hash = [1u8; 32]; + unsafe { + for proposal_id in [std::ptr::null(), not_base58.as_ptr()] { + assert_invalid_parameter(dash_sdk_decision_proposal_fetch(handle, proposal_id)); + assert_invalid_parameter(dash_sdk_decision_proposal_get_vote_poll_id( + handle, + proposal_id, + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_vote_poll_state( + handle, + proposal_id, + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_masternode_vote( + handle, + pro_tx_hash.as_ptr(), + proposal_id, + )); + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_owner( + handle, + proposal_id, + 0, + std::ptr::null(), + )); + } + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_end_date( + handle, + 0, + 0, + 0, + not_base58.as_ptr(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_created_at( + handle, + 0, + not_base58.as_ptr(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_masternode_vote( + handle, + std::ptr::null(), + id.as_ptr(), + )); + + let no_sdk = std::ptr::null(); + assert_invalid_parameter(dash_sdk_decision_proposal_fetch(no_sdk, id.as_ptr())); + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_end_date( + no_sdk, + 0, + 0, + 0, + std::ptr::null(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_owner( + no_sdk, + id.as_ptr(), + 0, + std::ptr::null(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_list_by_created_at( + no_sdk, + 0, + std::ptr::null(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_vote_poll_id( + no_sdk, + id.as_ptr(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_vote_poll_state( + no_sdk, + id.as_ptr(), + )); + assert_invalid_parameter(dash_sdk_decision_proposal_get_masternode_vote( + no_sdk, + pro_tx_hash.as_ptr(), + id.as_ptr(), + )); + destroy_mock_sdk_handle(handle); + } + } + + #[test] + fn should_page_from_a_limit_and_a_cursor() { + let cursor = base58(Identifier::new([5; 32])); + unsafe { + assert_eq!( + page(0, std::ptr::null()).expect("a page"), + DocumentsPage::default() + ); + assert_eq!( + page(20, cursor.as_ptr()).expect("a page"), + DocumentsPage { + limit: Some(20), + start_after: Some(Identifier::new([5; 32])), + } + ); + } + } +} diff --git a/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs b/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs new file mode 100644 index 00000000000..5b95c7b458d --- /dev/null +++ b/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs @@ -0,0 +1,991 @@ +//! Decision proposal writes: building a proposal to create, giving a stored proposal new +//! text, the properties the platform wallet puts, and a masternode's vote on a proposal. The +//! module docs of [`super`] show how they chain into the document put functions. + +use super::json::properties_json; +use super::{json_result, optional_str, required_identifier, required_str, sdk_wrapper}; +use crate::document::DashSDKDocumentCreateResult; +use crate::types::{DashSDKResultDataType, DocumentHandle, FFINetwork, SDKHandle}; +use crate::voting::masternode_vote::MasternodeVoter; +use crate::{DashSDKResult, FFIError}; +use dash_sdk::dpp::decision_proposal::{ + DECISION_PROPOSALS_CONTRACT_ID, PROPOSAL_DOCUMENT_TYPE_NAME, +}; +use dash_sdk::dpp::document::Document; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; +use dash_sdk::platform::decision_proposals::{ + build_decision_proposal_edit, DecisionProposalDocument, DecisionProposalEdit, + DecisionProposalInput, +}; +use std::ffi::{c_char, c_void, CString}; +use std::sync::OnceLock; + +/// What a new decision proposal asks and the rule it is decided by +/// (`dash_sdk_decision_proposal_build`). Platform refuses values outside the limits noted +/// here when the proposal is created; nothing is checked before. +#[repr(C)] +pub struct DashSDKDecisionProposalParams { + /// The question, in one line: 1 to 128 characters. NUL-terminated UTF-8, required. + pub title: *const c_char, + /// What a yes and a no mean: 1 to 4096 characters. NUL-terminated UTF-8, required. + pub description: *const c_char, + /// Where the proposal is discussed, a URI of at most 512 characters; null for none. + pub url: *const c_char, + /// The proposal passes when the yes voting power is strictly more than this percent of + /// the yes plus no voting power: 50 to 80. + pub pass_percent: u8, + /// The least yes plus no voting power the proposal needs, as a percent of the masternode + /// list's total voting power when it closes, rounded up: 0 to 50. + pub minimum_turnout_percent: u8, + /// When voting closes, in milliseconds since the Unix epoch: from a week to four weeks + /// after the block that creates the proposal, which is a little later than now. + pub end_date_ms: u64, +} + +/// New text for a stored decision proposal (`dash_sdk_decision_proposal_build_edit`). A +/// field left null keeps its value. Platform accepts the edit for a day after the proposal +/// was created; the rule and the end date never change. +#[repr(C)] +pub struct DashSDKDecisionProposalEdit { + /// The new title, NUL-terminated UTF-8; null keeps it. + pub title: *const c_char, + /// The new description, NUL-terminated UTF-8; null keeps it. + pub description: *const c_char, + /// The new url, NUL-terminated UTF-8; null keeps it, unless `remove_url` is set. + pub url: *const c_char, + /// Removes the url. Setting it together with a non-null `url` is refused. + pub remove_url: bool, +} + +/// The answer of a masternode's vote on a decision proposal, the `vote_choice` byte of +/// `dash_sdk_decision_proposal_cast_vote`, in the order of `YesNoAbstainVoteChoice`: +/// `0` = yes, `1` = no, `2` = abstain. +/// +/// The FFI parameter is a plain `u8`, not this enum: a C caller can pass any byte, and an +/// out-of-range value read as a `#[repr(u8)]` enum is undefined behavior. The byte is +/// compared with these discriminants, and any other value is refused with +/// `InvalidParameter`. Not exported to the C header for that reason; Swift and Kotlin mirror +/// the three values. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum YesNoVoteChoiceFFI { + Yes = 0, + No = 1, + Abstain = 2, +} + +/// The vote choice the `vote_choice` byte names. +fn vote_choice_from_ffi(vote_choice: u8) -> Result { + match vote_choice { + x if x == YesNoVoteChoiceFFI::Yes as u8 => Ok(YesNoAbstainVoteChoice::Yes), + x if x == YesNoVoteChoiceFFI::No as u8 => Ok(YesNoAbstainVoteChoice::No), + x if x == YesNoVoteChoiceFFI::Abstain as u8 => Ok(YesNoAbstainVoteChoice::Abstain), + other => Err(FFIError::InvalidParameter(format!( + "vote_choice must be 0 (yes), 1 (no) or 2 (abstain); got {other}" + ))), + } +} + +/// The id of the decision proposals system contract, base58: the `data_contract_id` the +/// document put functions take for a proposal. +/// +/// # Returns +/// A static NUL-terminated string. Do not free it. +#[no_mangle] +pub extern "C" fn dash_sdk_decision_proposal_contract_id() -> *const c_char { + static CONTRACT_ID: OnceLock = OnceLock::new(); + CONTRACT_ID + .get_or_init(|| { + CString::new(DECISION_PROPOSALS_CONTRACT_ID.to_string(Encoding::Base58)) + .expect("base58 has no NUL byte") + }) + .as_ptr() +} + +/// Writes the 32 bytes of the decision proposals system contract id to `out_contract_id`: +/// the `contract_id` the platform wallet's document functions take for a proposal. +/// +/// # Returns +/// `false` when `out_contract_id` is null, `true` once the id is written. +/// +/// # Safety +/// `out_contract_id` must be null or point to 32 writable bytes. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_contract_id_bytes( + out_contract_id: *mut u8, +) -> bool { + if out_contract_id.is_null() { + return false; + } + std::slice::from_raw_parts_mut(out_contract_id, 32) + .copy_from_slice(DECISION_PROPOSALS_CONTRACT_ID.as_slice()); + true +} + +/// The document type name of a proposal (`"proposal"`), the `document_type_name` the +/// document put and platform wallet functions take. +/// +/// # Returns +/// A static NUL-terminated string. Do not free it. +#[no_mangle] +pub extern "C" fn dash_sdk_decision_proposal_document_type_name() -> *const c_char { + static DOCUMENT_TYPE_NAME: OnceLock = OnceLock::new(); + DOCUMENT_TYPE_NAME + .get_or_init(|| { + CString::new(PROPOSAL_DOCUMENT_TYPE_NAME).expect("the type name has no NUL byte") + }) + .as_ptr() +} + +/// Builds a decision proposal of `owner_identity_id`: a `proposal` document of the decision +/// proposals contract asking what `params` says, with a fresh entropy. Nothing is broadcast. +/// +/// Put the document with `dash_sdk_document_put_to_platform_and_wait(sdk_handle, +/// result.document_handle, dash_sdk_decision_proposal_contract_id(), +/// dash_sdk_decision_proposal_document_type_name(), &result.entropy, key, signer, ...)`, or +/// pass `dash_sdk_decision_proposal_properties_json(result.document_handle)` to the platform +/// wallet's `platform_wallet_create_document_with_signer`. The create states the decision +/// proposal fund (0.5 Dash), paid by the owner on top of the fees. Platform refuses a rule +/// outside the contract's limits and an end date less than a week or more than four weeks +/// after the block that creates the proposal. The document id is a placeholder until the put +/// returns the stored document. +/// +/// Reads the decision proposals contract from the context provider, or fetches it; a network +/// below protocol version 15 has no such contract, which is an error. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `owner_identity_id` - The proposer's identity id, base58. +/// * `params` - The question and its rule. +/// +/// # Returns +/// On success `data` points to a `DashSDKDocumentCreateResult` (`data_type` is `NoData`, as +/// for `dash_sdk_document_create`): the document handle and the 32-byte entropy the put +/// must reuse. Free the struct with `dash_sdk_document_create_result_free`, which leaves the +/// document handle alive, and the handle with `dash_sdk_document_handle_destroy`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `owner_identity_id` must be a valid pointer to a NUL-terminated string. +/// - `params` must be a valid pointer to a `DashSDKDecisionProposalParams` whose strings are +/// NUL-terminated (`url` may be null). All must stay valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_build( + sdk_handle: *const SDKHandle, + owner_identity_id: *const c_char, + params: *const DashSDKDecisionProposalParams, +) -> DashSDKResult { + match build_inner(sdk_handle, owner_identity_id, params) { + Ok(built) => create_result(built), + Err(error) => DashSDKResult::error(error.into()), + } +} + +/// `built` as `dash_sdk_document_create` returns a document to create: a +/// `DashSDKDocumentCreateResult` holding the document handle and the entropy. +fn create_result(built: DecisionProposalDocument) -> DashSDKResult { + let document_handle = Box::into_raw(Box::new(built.document)) as *mut DocumentHandle; + let create_result = Box::new(DashSDKDocumentCreateResult { + document_handle, + entropy: built.entropy.0, + }); + DashSDKResult::success(Box::into_raw(create_result) as *mut c_void) +} + +unsafe fn build_inner( + sdk_handle: *const SDKHandle, + owner_identity_id: *const c_char, + params: *const DashSDKDecisionProposalParams, +) -> Result { + let wrapper = sdk_wrapper(sdk_handle)?; + let owner_id = required_identifier(owner_identity_id, "owner_identity_id")?; + let input = proposal_input(params)?; + Ok(wrapper + .runtime + .block_on(wrapper.sdk.build_decision_proposal(owner_id, &input))?) +} + +/// The proposal input `params` holds. +/// +/// # Safety +/// `params` must be null or point to a `DashSDKDecisionProposalParams` whose string fields +/// are null or NUL-terminated. +unsafe fn proposal_input( + params: *const DashSDKDecisionProposalParams, +) -> Result { + if params.is_null() { + return Err(FFIError::InvalidParameter("params is null".to_string())); + } + let params = &*params; + Ok(DecisionProposalInput { + title: required_str(params.title, "title")?.to_string(), + description: required_str(params.description, "description")?.to_string(), + url: optional_str(params.url, "url")?.map(str::to_string), + pass_percent: params.pass_percent, + minimum_turnout_percent: params.minimum_turnout_percent, + end_date: params.end_date_ms, + }) +} + +/// Fetches a decision proposal by id as a document handle, the stored proposal +/// `dash_sdk_decision_proposal_build_edit` edits. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `proposal_id` - The proposal's id, base58. +/// +/// # Returns +/// A document handle (`data_type` `ResultDocumentHandle`), or no data when there is no such +/// proposal. Free the handle with `dash_sdk_document_handle_destroy`. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `proposal_id` must be a valid pointer to a NUL-terminated string for the duration of the +/// call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_fetch_document( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> DashSDKResult { + match fetch_document_inner(sdk_handle, proposal_id) { + Ok(Some(document)) => document_handle_result(document), + Ok(None) => DashSDKResult::success(std::ptr::null_mut()), + Err(error) => DashSDKResult::error(error.into()), + } +} + +unsafe fn fetch_document_inner( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, +) -> Result, FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let proposal_id = required_identifier(proposal_id, "proposal_id")?; + Ok(wrapper + .runtime + .block_on(wrapper.sdk.fetch_decision_proposal(proposal_id))?) +} + +fn document_handle_result(document: Document) -> DashSDKResult { + DashSDKResult::success_handle( + Box::into_raw(Box::new(document)) as *mut c_void, + DashSDKResultDataType::ResultDocumentHandle, + ) +} + +/// Gives a stored decision proposal new text: a copy of `proposal` with the text `edit` +/// sets and its next revision. Nothing is broadcast and `proposal` is left as it is. +/// +/// rs-sdk refuses, with `InvalidParameter`, a document that is not a `proposal` of the +/// decision proposals contract (as `data_contract_id` and `document_type_name` name it), an +/// edit that leaves the text as it is (a replace that changes nothing still pays its fees), +/// and a document past its last revision. Platform refuses the replace more than a day after +/// the proposal was created, and any change to its rule or end date. +/// +/// Put the edited document with `dash_sdk_document_put_to_platform_and_wait`: its revision is +/// above 1, so it is put as a replace, and the `entropy` argument is not read (pass any 32 +/// bytes). Do not use `dash_sdk_document_replace_on_platform_and_wait`, which moves the +/// revision once more and is refused. For the platform wallet, pass +/// `dash_sdk_decision_proposal_properties_json` of the edited document to +/// `platform_wallet_document_replace` with the proposal's id; it fetches the stored proposal +/// and moves it to the same revision. +/// +/// # Parameters +/// * `proposal` - The proposal as stored, from `dash_sdk_decision_proposal_fetch_document`. +/// * `data_contract_id` - The contract the document belongs to, base58 +/// (`dash_sdk_decision_proposal_contract_id`). +/// * `document_type_name` - The document's type (`dash_sdk_decision_proposal_document_type_name`). +/// * `edit` - The new text. +/// +/// # Returns +/// A new document handle (`data_type` `ResultDocumentHandle`). Free it with +/// `dash_sdk_document_handle_destroy`; the caller still owns and frees `proposal`. +/// +/// # Safety +/// - `proposal` must be a valid document handle from this SDK. +/// - `data_contract_id` and `document_type_name` must be valid pointers to NUL-terminated +/// strings. +/// - `edit` must be a valid pointer to a `DashSDKDecisionProposalEdit` whose string fields are +/// null or NUL-terminated. All must stay valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_build_edit( + proposal: *const DocumentHandle, + data_contract_id: *const c_char, + document_type_name: *const c_char, + edit: *const DashSDKDecisionProposalEdit, +) -> DashSDKResult { + match build_edit_inner(proposal, data_contract_id, document_type_name, edit) { + Ok(document) => document_handle_result(document), + Err(error) => DashSDKResult::error(error.into()), + } +} + +unsafe fn build_edit_inner( + proposal: *const DocumentHandle, + data_contract_id: *const c_char, + document_type_name: *const c_char, + edit: *const DashSDKDecisionProposalEdit, +) -> Result { + if proposal.is_null() { + return Err(FFIError::InvalidParameter("proposal is null".to_string())); + } + let proposal = &*(proposal as *const Document); + let data_contract_id = required_identifier(data_contract_id, "data_contract_id")?; + let document_type_name = required_str(document_type_name, "document_type_name")?; + let edit = proposal_edit(edit)?; + // The edit reads nothing but its arguments, so whatever it refuses is the caller's input. + build_decision_proposal_edit(&data_contract_id, document_type_name, proposal, &edit) + .map_err(|e| FFIError::InvalidParameter(e.to_string())) +} + +/// The edit `edit` holds. +/// +/// # Safety +/// `edit` must be null or point to a `DashSDKDecisionProposalEdit` whose string fields are +/// null or NUL-terminated. +unsafe fn proposal_edit( + edit: *const DashSDKDecisionProposalEdit, +) -> Result { + if edit.is_null() { + return Err(FFIError::InvalidParameter("edit is null".to_string())); + } + let edit = &*edit; + let url = match (optional_str(edit.url, "url")?, edit.remove_url) { + (Some(_), true) => { + return Err(FFIError::InvalidParameter( + "url and remove_url cannot both be set".to_string(), + )) + } + (Some(url), false) => Some(Some(url.to_string())), + (None, true) => Some(None), + (None, false) => None, + }; + Ok(DecisionProposalEdit { + title: optional_str(edit.title, "title")?.map(str::to_string), + description: optional_str(edit.description, "description")?.map(str::to_string), + url, + }) +} + +/// The properties of a proposal document as a JSON object: the `properties_json` the platform +/// wallet's `platform_wallet_create_document_with_signer` (for a document from +/// `dash_sdk_decision_proposal_build`) and `platform_wallet_document_replace` (for one from +/// `dash_sdk_decision_proposal_build_edit`) take, for example +/// `{"title": "...", "description": "...", "url": "...", "passPercent": 60, +/// "minimumTurnoutPercent": 10, "endDate": 1701209600000}`. A proposal without a url has no +/// `url` key. +/// +/// # Returns +/// A JSON string. Free it with `dash_sdk_string_free`; the caller still owns `document`. +/// +/// # Safety +/// `document` must be a valid document handle from this SDK. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_properties_json( + document: *const DocumentHandle, +) -> DashSDKResult { + if document.is_null() { + return DashSDKResult::error( + FFIError::InvalidParameter("document is null".to_string()).into(), + ); + } + json_result(properties_json(&*(document as *const Document)).map(Some)) +} + +/// Votes on a decision proposal for a masternode: builds the vote on the proposal's yes/no +/// poll, signs it with the masternode voting key derived from `voting_private_key`, +/// broadcasts it for `voter_pro_tx_hash` and waits for the response. Every argument is read +/// before anything reaches the network. A masternode may vote 5 times on a proposal, but not +/// give the answer it already gave; polls exist from protocol version 15. +/// +/// The signer, the voting key and the voter identity are derived exactly as +/// `dash_sdk_contested_resource_cast_vote` derives them, and a refused voting key is +/// explained the same way. +/// +/// # Parameters +/// * `sdk_handle` - Handle to the SDK instance. +/// * `proposal_id` - The proposal's id, base58. +/// * `vote_choice` - `0` = yes, `1` = no, `2` = abstain. Any other value is refused with +/// `InvalidParameter`. +/// * `voter_pro_tx_hash` - Pointer to the masternode's 32-byte pro_tx_hash in wire order: the +/// orientation `Txid` stores, which a parsed ProRegTx yields and a wallet holds. This is NOT +/// the byte order of the hex Core displays, which is its reverse. The bytes are reversed +/// here, as `dash_sdk_contested_resource_cast_vote` reverses them; passing display order +/// names a voter identity that never existed. +/// * `voting_private_key` - Pointer to the 32-byte masternode voting private key. It never +/// leaves this call. +/// * `network` - The network, used for the WIF encoding inside the signer only. +/// +/// # Returns +/// No data on success, or an error. A vote from a wallet that is not a masternode fails with +/// a no-voter-identity error from Platform, the expected outcome. +/// +/// # Safety +/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. +/// - `proposal_id` must be a valid pointer to a NUL-terminated string. +/// - `voter_pro_tx_hash` and `voting_private_key` must each point to 32 readable bytes. All +/// must stay valid for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn dash_sdk_decision_proposal_cast_vote( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, + vote_choice: u8, + voter_pro_tx_hash: *const u8, + voting_private_key: *const u8, + network: FFINetwork, +) -> DashSDKResult { + match cast_vote_inner( + sdk_handle, + proposal_id, + vote_choice, + voter_pro_tx_hash, + voting_private_key, + network, + ) { + Ok(()) => DashSDKResult::success(std::ptr::null_mut()), + Err(error) => DashSDKResult::error(error.into()), + } +} + +unsafe fn cast_vote_inner( + sdk_handle: *const SDKHandle, + proposal_id: *const c_char, + vote_choice: u8, + voter_pro_tx_hash: *const u8, + voting_private_key: *const u8, + network: FFINetwork, +) -> Result<(), FFIError> { + let wrapper = sdk_wrapper(sdk_handle)?; + let proposal_id = required_identifier(proposal_id, "proposal_id")?; + let vote_choice = vote_choice_from_ffi(vote_choice)?; + let voter = MasternodeVoter::from_raw(voter_pro_tx_hash, voting_private_key, network)?; + wrapper.runtime.block_on(async { + let vote = wrapper + .sdk + .decision_proposal_vote(proposal_id, vote_choice) + .await?; + voter.put_vote(&wrapper.sdk, &vote).await + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decision_proposal::json::tests::{ + input, proposals_contract, stored_proposal, END_DATE, + }; + use crate::decision_proposal::json::vote_choice_name; + use crate::document::{build_document_from_properties, parse_document_properties_json}; + use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; + use crate::{ + dash_sdk_document_create_result_free, dash_sdk_document_handle_destroy, + dash_sdk_error_free, dash_sdk_string_free, DashSDKErrorCode, + }; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::document::{DocumentV0Getters, DocumentV0Setters}; + use dash_sdk::dpp::platform_value::{Bytes32, Identifier}; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::decision_proposals::{DecisionProposal, DecisionProposalInput}; + use std::ffi::CStr; + + /// `result`'s error code; the error is freed. + unsafe fn error_code(result: DashSDKResult) -> DashSDKErrorCode { + assert!(result.data.is_null()); + assert!(!result.error.is_null(), "expected an error"); + let code = (*result.error).code; + dash_sdk_error_free(result.error); + code + } + + /// The document `result` holds as a handle; the handle is freed. + unsafe fn take_document(result: DashSDKResult) -> Document { + assert!(result.error.is_null(), "{:?}", error_message(&result)); + assert_eq!( + result.data_type, + DashSDKResultDataType::ResultDocumentHandle + ); + let document = (*(result.data as *const Document)).clone(); + dash_sdk_document_handle_destroy(result.data as *mut DocumentHandle); + document + } + + unsafe fn error_message(result: &DashSDKResult) -> Option { + (!result.error.is_null()).then(|| { + CStr::from_ptr((*result.error).message) + .to_string_lossy() + .into_owned() + }) + } + + /// The string `result` holds; the string is freed. + unsafe fn take_string(result: DashSDKResult) -> String { + assert!(result.error.is_null(), "{:?}", error_message(&result)); + assert_eq!(result.data_type, DashSDKResultDataType::String); + let string = CStr::from_ptr(result.data as *const c_char) + .to_str() + .expect("UTF-8") + .to_string(); + dash_sdk_string_free(result.data as *mut c_char); + string + } + + fn c_string(value: &str) -> CString { + CString::new(value).expect("no NUL") + } + + fn no_edit() -> DashSDKDecisionProposalEdit { + DashSDKDecisionProposalEdit { + title: std::ptr::null(), + description: std::ptr::null(), + url: std::ptr::null(), + remove_url: false, + } + } + + /// `edit` applied by the FFI to `proposal`, named as a decision proposal. + unsafe fn edit_proposal( + proposal: &Document, + edit: &DashSDKDecisionProposalEdit, + ) -> DashSDKResult { + dash_sdk_decision_proposal_build_edit( + proposal as *const Document as *const DocumentHandle, + dash_sdk_decision_proposal_contract_id(), + dash_sdk_decision_proposal_document_type_name(), + edit, + ) + } + + #[test] + fn should_name_the_decision_proposals_contract_and_the_proposal_type() { + let contract = proposals_contract(); + unsafe { + assert_eq!( + CStr::from_ptr(dash_sdk_decision_proposal_contract_id()) + .to_str() + .expect("UTF-8"), + contract.id().to_string(Encoding::Base58) + ); + assert_eq!( + CStr::from_ptr(dash_sdk_decision_proposal_document_type_name()) + .to_str() + .expect("UTF-8"), + "proposal" + ); + assert!(contract.document_type_for_name("proposal").is_ok()); + let mut bytes = [0u8; 32]; + assert!(dash_sdk_decision_proposal_contract_id_bytes( + bytes.as_mut_ptr() + )); + assert_eq!(Identifier::new(bytes), contract.id()); + assert!(!dash_sdk_decision_proposal_contract_id_bytes( + std::ptr::null_mut() + )); + } + } + + #[test] + fn should_map_each_vote_choice_byte_and_refuse_the_rest() { + assert_eq!( + vote_choice_from_ffi(0).ok(), + Some(YesNoAbstainVoteChoice::Yes) + ); + assert_eq!( + vote_choice_from_ffi(1).ok(), + Some(YesNoAbstainVoteChoice::No) + ); + assert_eq!( + vote_choice_from_ffi(2).ok(), + Some(YesNoAbstainVoteChoice::Abstain) + ); + for refused in [3, 99, u8::MAX] { + assert!(matches!( + vote_choice_from_ffi(refused), + Err(FFIError::InvalidParameter(_)) + )); + } + // The byte and the name a masternode's answer is read back with agree + for (byte, name) in [(0, "yes"), (1, "no"), (2, "abstain")] { + assert_eq!( + vote_choice_name(vote_choice_from_ffi(byte).expect("a choice")), + name + ); + } + } + + #[test] + fn should_read_the_proposal_params() { + let title = c_string("Raise the block size?"); + let description = c_string("Yes doubles it; no keeps it."); + let url = c_string("https://forum.dash.org/t/block-size"); + let mut params = DashSDKDecisionProposalParams { + title: title.as_ptr(), + description: description.as_ptr(), + url: url.as_ptr(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: END_DATE, + }; + unsafe { + assert_eq!(proposal_input(¶ms).expect("reads"), input()); + params.url = std::ptr::null(); + assert_eq!(proposal_input(¶ms).expect("reads").url, None); + params.title = std::ptr::null(); + assert!(matches!( + proposal_input(¶ms), + Err(FFIError::InvalidParameter(_)) + )); + assert!(matches!( + proposal_input(std::ptr::null()), + Err(FFIError::InvalidParameter(_)) + )); + } + } + + /// The build refuses arguments it cannot read before it asks the network anything. + #[test] + fn should_refuse_a_build_with_unreadable_arguments() { + let handle = create_mock_sdk_handle(); + let owner = c_string(&Identifier::new([3; 32]).to_string(Encoding::Base58)); + let not_base58 = c_string("0OIl"); + let title = c_string("Raise the block size?"); + let description = c_string("Yes doubles it; no keeps it."); + let params = DashSDKDecisionProposalParams { + title: title.as_ptr(), + description: description.as_ptr(), + url: std::ptr::null(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: END_DATE, + }; + let no_title = DashSDKDecisionProposalParams { + title: std::ptr::null(), + ..params + }; + unsafe { + for result in [ + dash_sdk_decision_proposal_build(std::ptr::null(), owner.as_ptr(), ¶ms), + dash_sdk_decision_proposal_build(handle, std::ptr::null(), ¶ms), + dash_sdk_decision_proposal_build(handle, not_base58.as_ptr(), ¶ms), + dash_sdk_decision_proposal_build(handle, owner.as_ptr(), std::ptr::null()), + dash_sdk_decision_proposal_build(handle, owner.as_ptr(), &no_title), + ] { + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + destroy_mock_sdk_handle(handle); + } + } + + #[test] + fn should_give_a_proposal_new_text_and_its_next_revision() { + let proposal = stored_proposal(&input()); + let title = c_string("Raise the block size to 4 MB?"); + let url = c_string("https://forum.dash.org/t/block-size-4mb"); + unsafe { + let edited = take_document(edit_proposal( + &proposal, + &DashSDKDecisionProposalEdit { + title: title.as_ptr(), + url: url.as_ptr(), + ..no_edit() + }, + )); + assert_eq!(edited.id(), proposal.id()); + assert_eq!(edited.revision(), Some(2)); + let read = DecisionProposal::from_document(edited).expect("a proposal"); + assert_eq!(read.title, "Raise the block size to 4 MB?"); + assert_eq!(read.description, input().description); + assert_eq!( + read.url.as_deref(), + Some("https://forum.dash.org/t/block-size-4mb") + ); + assert_eq!( + ( + read.pass_percent, + read.minimum_turnout_percent, + read.end_date + ), + (60, 10, END_DATE) + ); + // The stored proposal the caller passed is left as it was + assert_eq!(proposal.revision(), Some(1)); + + let without_url = take_document(edit_proposal( + &proposal, + &DashSDKDecisionProposalEdit { + remove_url: true, + ..no_edit() + }, + )); + assert_eq!( + DecisionProposal::from_document(without_url) + .expect("a proposal") + .url, + None + ); + } + } + + /// rs-sdk refuses these edits; the FFI passes its refusal on as `InvalidParameter`. + #[test] + fn should_refuse_an_edit_that_changes_nothing_or_is_not_of_a_proposal() { + let proposal = stored_proposal(&input()); + let same_title = c_string(&input().title); + let new_title = c_string("Another question?"); + let same_url = c_string(input().url.as_deref().expect("a url")); + let other_contract = c_string(&Identifier::new([1; 32]).to_string(Encoding::Base58)); + let other_type = c_string("note"); + unsafe { + for edit in [ + no_edit(), + DashSDKDecisionProposalEdit { + title: same_title.as_ptr(), + ..no_edit() + }, + DashSDKDecisionProposalEdit { + url: same_url.as_ptr(), + ..no_edit() + }, + ] { + let result = edit_proposal(&proposal, &edit); + assert!(error_message(&result) + .expect("refused") + .contains("changes nothing")); + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + + let retitle = DashSDKDecisionProposalEdit { + title: new_title.as_ptr(), + ..no_edit() + }; + for (data_contract_id, document_type_name) in [ + ( + other_contract.as_ptr(), + dash_sdk_decision_proposal_document_type_name(), + ), + ( + dash_sdk_decision_proposal_contract_id(), + other_type.as_ptr(), + ), + ] { + let result = dash_sdk_decision_proposal_build_edit( + &proposal as *const Document as *const DocumentHandle, + data_contract_id, + document_type_name, + &retitle, + ); + assert!(error_message(&result) + .expect("refused") + .contains("is not a decision proposal")); + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + + let mut last_revision = proposal.clone(); + last_revision.set_revision(Some(u64::MAX)); + assert_eq!( + error_code(edit_proposal(&last_revision, &retitle)), + DashSDKErrorCode::InvalidParameter + ); + } + } + + #[test] + fn should_refuse_an_edit_it_cannot_read() { + let proposal = stored_proposal(&input()); + let url = c_string("https://example.com"); + let not_base58 = c_string("0OIl"); + let contradictory = DashSDKDecisionProposalEdit { + url: url.as_ptr(), + remove_url: true, + ..no_edit() + }; + let proposal_handle = &proposal as *const Document as *const DocumentHandle; + unsafe { + for result in [ + edit_proposal(&proposal, &contradictory), + dash_sdk_decision_proposal_build_edit( + std::ptr::null(), + dash_sdk_decision_proposal_contract_id(), + dash_sdk_decision_proposal_document_type_name(), + &no_edit(), + ), + dash_sdk_decision_proposal_build_edit( + proposal_handle, + not_base58.as_ptr(), + dash_sdk_decision_proposal_document_type_name(), + &no_edit(), + ), + dash_sdk_decision_proposal_build_edit( + proposal_handle, + dash_sdk_decision_proposal_contract_id(), + std::ptr::null(), + &no_edit(), + ), + dash_sdk_decision_proposal_build_edit( + proposal_handle, + dash_sdk_decision_proposal_contract_id(), + dash_sdk_decision_proposal_document_type_name(), + std::ptr::null(), + ), + ] { + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + } + } + + /// The properties JSON, read back the way a document create reads its properties (the + /// platform wallet's create and `dash_sdk_document_create` share the parse, sanitize and + /// build steps), gives a document with exactly the properties rs-sdk built, value types + /// included. + #[test] + fn should_write_properties_a_document_create_reads_back_into_the_same_proposal() { + let platform_version = PlatformVersion::latest(); + let contract = proposals_contract(); + let document_type = contract + .document_type_for_name("proposal") + .expect("the proposal type"); + for proposal in [ + stored_proposal(&input()), + stored_proposal(&DecisionProposalInput { + url: None, + ..input() + }), + ] { + unsafe { + let json = take_string(dash_sdk_decision_proposal_properties_json( + &proposal as *const Document as *const DocumentHandle, + )); + let properties = parse_document_properties_json(&json) + .ok() + .expect("an object"); + let created = build_document_from_properties( + document_type, + properties, + proposal.owner_id(), + [9; 32], + platform_version, + ) + .expect("builds"); + assert_eq!(created.properties(), proposal.properties(), "{json}"); + } + } + unsafe { + assert_eq!( + error_code(dash_sdk_decision_proposal_properties_json(std::ptr::null())), + DashSDKErrorCode::InvalidParameter + ); + } + } + + /// The vote reads every argument before it builds the vote, which asks the network for + /// its protocol version. + #[test] + fn should_refuse_a_vote_with_unreadable_arguments() { + let handle = create_mock_sdk_handle(); + let proposal_id = c_string(&Identifier::new([4; 32]).to_string(Encoding::Base58)); + let not_base58 = c_string("0OIl"); + let pro_tx_hash = [1u8; 32]; + let voting_key = [2u8; 32]; + let zero_key = [0u8; 32]; + unsafe { + for result in [ + dash_sdk_decision_proposal_cast_vote( + std::ptr::null(), + proposal_id.as_ptr(), + 0, + pro_tx_hash.as_ptr(), + voting_key.as_ptr(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + std::ptr::null(), + 0, + pro_tx_hash.as_ptr(), + voting_key.as_ptr(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + not_base58.as_ptr(), + 0, + pro_tx_hash.as_ptr(), + voting_key.as_ptr(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + proposal_id.as_ptr(), + 3, + pro_tx_hash.as_ptr(), + voting_key.as_ptr(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + proposal_id.as_ptr(), + 1, + std::ptr::null(), + voting_key.as_ptr(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + proposal_id.as_ptr(), + 1, + pro_tx_hash.as_ptr(), + std::ptr::null(), + FFINetwork::Testnet, + ), + dash_sdk_decision_proposal_cast_vote( + handle, + proposal_id.as_ptr(), + 2, + pro_tx_hash.as_ptr(), + zero_key.as_ptr(), + FFINetwork::Testnet, + ), + ] { + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + destroy_mock_sdk_handle(handle); + } + } + + #[test] + fn should_refuse_to_fetch_a_document_with_unreadable_arguments() { + let handle = create_mock_sdk_handle(); + let not_base58 = c_string("0OIl"); + let id = c_string(&Identifier::new([4; 32]).to_string(Encoding::Base58)); + unsafe { + for result in [ + dash_sdk_decision_proposal_fetch_document(std::ptr::null(), id.as_ptr()), + dash_sdk_decision_proposal_fetch_document(handle, std::ptr::null()), + dash_sdk_decision_proposal_fetch_document(handle, not_base58.as_ptr()), + ] { + assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); + } + destroy_mock_sdk_handle(handle); + } + } + + /// The built proposal reaches the caller as `dash_sdk_document_create`'s result does, and + /// frees the same way: the struct, then the document handle it held. + #[test] + fn should_hand_a_built_proposal_over_as_a_document_create_result() { + let document = stored_proposal(&input()); + let result = create_result(DecisionProposalDocument { + document: document.clone(), + entropy: Bytes32::new([9; 32]), + }); + assert!(result.error.is_null()); + assert_eq!(result.data_type, DashSDKResultDataType::NoData); + unsafe { + let created = result.data as *mut DashSDKDocumentCreateResult; + assert_eq!((*created).entropy, [9; 32]); + let handle = (*created).document_handle; + dash_sdk_document_create_result_free(created); + assert_eq!(*(handle as *const Document), document); + dash_sdk_document_handle_destroy(handle); + } + } +} diff --git a/packages/rs-sdk-ffi/src/document/mod.rs b/packages/rs-sdk-ffi/src/document/mod.rs index 2a388bae42d..fb2d40a6775 100644 --- a/packages/rs-sdk-ffi/src/document/mod.rs +++ b/packages/rs-sdk-ffi/src/document/mod.rs @@ -12,7 +12,10 @@ mod transfer; mod util; // Re-export functions from submodules -pub use create::{dash_sdk_document_create, DashSDKDocumentCreateParams}; +pub use create::{ + dash_sdk_document_create, dash_sdk_document_create_result_free, DashSDKDocumentCreateParams, + DashSDKDocumentCreateResult, +}; pub use delete::{dash_sdk_document_delete, dash_sdk_document_delete_and_wait}; pub use price::{ dash_sdk_document_update_price_of_document, dash_sdk_document_update_price_of_document_and_wait, diff --git a/packages/rs-sdk-ffi/src/lib.rs b/packages/rs-sdk-ffi/src/lib.rs index 8b7ac150cd1..e6d8afb6029 100644 --- a/packages/rs-sdk-ffi/src/lib.rs +++ b/packages/rs-sdk-ffi/src/lib.rs @@ -12,6 +12,7 @@ mod context_callbacks; pub mod context_provider; mod crypto; mod data_contract; +mod decision_proposal; mod document; mod dpns; mod error; @@ -44,6 +45,7 @@ pub use context_callbacks::*; pub use context_provider::*; pub use crypto::*; pub use data_contract::*; +pub use decision_proposal::*; pub use document::*; pub use dpns::*; pub use error::*; diff --git a/packages/rs-sdk-ffi/src/voting/masternode_vote.rs b/packages/rs-sdk-ffi/src/voting/masternode_vote.rs new file mode 100644 index 00000000000..a7c633f1272 --- /dev/null +++ b/packages/rs-sdk-ffi/src/voting/masternode_vote.rs @@ -0,0 +1,317 @@ +//! Signing and broadcasting a masternode vote from the masternode's voting private key. +//! +//! Shared by every FFI that casts a masternode vote: a contested resource vote +//! (`dash_sdk_contested_resource_cast_vote`) and an answer on a decision proposal +//! (`dash_sdk_decision_proposal_cast_vote`). Each builds its `Vote`; this module turns the +//! caller's raw key material into what [`PutVote`] needs, broadcasts, and explains a voting +//! key rejection. +//! +//! # Who can cast a vote +//! +//! A vote is cast by a masternode with its voting key: an `ECDSA_HASH160` key whose 20-byte +//! data is the hash160 of the voting public key, tied to the masternode's pro_tx_hash (see +//! `get_voter_identity_key_v0` in rs-drive-abci). The voter identity is derived from both as +//! `create_voter_identifier(pro_tx_hash, voting_address)`. +//! +//! The FFI therefore takes the raw 32-byte voting private key plus the 32-byte pro_tx_hash. +//! From the private key it derives a [`SingleKeySigner`] that signs the transition, and +//! `hash160(pubkey)`, the voting address, which names both the voter identity and the key on +//! it. +//! +//! The voting key is built locally, not fetched ([`voter_identity_voting_key`]): Platform +//! always gives a voter identity's voting key id 0, and a rotation creates a different +//! identity (the identifier includes the voting address) whose key is also 0. +//! `SingleKeySigner::can_sign_with` recomputes the same `hash160(pubkey)` from the private +//! key, so key and signer agree by construction. +//! +//! # pro_tx_hash byte order +//! +//! Callers pass the pro_tx_hash in wire order: the orientation `Txid` stores, which is what a +//! parsed ProRegTx yields (`reg.txid()`) and what a wallet holds. Platform names masternodes +//! by the other orientation: `ProTxHash` is `#[hash_newtype(forward)]` while `Txid` is not, +//! so `ProTxHash::to_byte_array()` is display order, the reverse of the `Txid` bytes of the +//! same transaction, and drive-abci derives the voter identity from +//! `masternode.pro_tx_hash.to_byte_array()`. [`pro_tx_hash_from_wire_order`] reverses once, +//! and the reversed value is used for the voter identity, the transition and the queries +//! keyed by masternode, so they cannot drift apart. +//! +//! # Diagnosis is deferred to the failure path +//! +//! Platform reports two different failures as the same opaque "Public key 0 doesn't exist": +//! no voter identity exists for this `(pro_tx_hash, voting address)` pair, or a rotation +//! disabled the old identity's keys (`update_voter_identity_v0`), so key 0 exists but is +//! unusable. Telling them apart needs the identity, and fetching it before every cast would +//! spend a Platform round trip per vote on runs that overwhelmingly succeed. So the fetch +//! happens only after a broadcast failed on the voting key ([`is_voting_key_failure`]). A +//! diagnosis replaces the opaque error; an unrelated failure (network, fees, a closed poll) +//! is returned unchanged rather than recast as a key problem. +//! +//! A regular wallet is not a masternode and has no voting key, so its vote reaches a +//! deterministic rejection (no voter identity), the expected end state when testing without +//! real masternode credentials. + +use crate::types::{FFINetwork, Network}; +use crate::FFIError; +use dash_sdk::dpp::dashcore::hashes::{hash160, Hash}; +use dash_sdk::dpp::dashcore::ProTxHash; +use dash_sdk::dpp::identity::IdentityPublicKey; +use dash_sdk::dpp::platform_value::Identifier; +use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; +use dash_sdk::dpp::voting::votes::Vote; +use dash_sdk::platform::transition::masternode_vote_keys::{ + diagnose_voting_key_failure, is_voting_key_failure, voter_identity_voting_key, VotingKeyProblem, +}; +use dash_sdk::platform::transition::vote::PutVote; +use dash_sdk::Sdk; +use simple_signer::SingleKeySigner; +use zeroize::Zeroizing; + +/// A masternode about to vote: its pro_tx_hash as Platform names it, its voting address, and +/// the signer and key its voting private key gives. +pub(crate) struct MasternodeVoter { + /// The pro_tx_hash in `ProTxHash` order, the wire-order bytes the caller passed reversed. + pro_tx_hash: [u8; 32], + /// `hash160` of the compressed voting public key. + voting_address: [u8; 20], + /// Signs with the voting private key. + signer: SingleKeySigner, + /// The `ECDSA_HASH160` voting key Platform holds for the voter identity. + voting_key: IdentityPublicKey, +} + +impl MasternodeVoter { + /// Reads the masternode from the raw FFI arguments: the pro_tx_hash in wire order (see + /// the module docs) and the 32-byte voting private key. Nothing reaches the network. + /// + /// # Safety + /// `voter_pro_tx_hash` and `voting_private_key` must each be null or point to 32 readable + /// bytes for the duration of the call. + pub(crate) unsafe fn from_raw( + voter_pro_tx_hash: *const u8, + voting_private_key: *const u8, + network: FFINetwork, + ) -> Result { + let pro_tx_hash = pro_tx_hash_from_wire_order(voter_pro_tx_hash)?.to_buffer(); + if voting_private_key.is_null() { + return Err(invalid("voting_private_key is null")); + } + let network: Network = network.into(); + + // Hold the private key in a zeroizing buffer so it does not linger. + let mut key = Zeroizing::new([0u8; 32]); + key.copy_from_slice(std::slice::from_raw_parts(voting_private_key, 32)); + + let signer = SingleKeySigner::new_from_slice(key.as_slice(), network) + .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; + let secret_key = SecretKey::from_secret_bytes(*key) + .map_err(|e| invalid(&format!("Invalid voting private key: {}", e)))?; + let public_key = PublicKey::from_secret_key(&secret_key); + let voting_address = hash160::Hash::hash(&public_key.serialize()).to_byte_array(); + + Ok(MasternodeVoter { + pro_tx_hash, + voting_address, + signer, + voting_key: voter_identity_voting_key(&voting_address), + }) + } + + /// The pro_tx_hash as Platform names the masternode. + pub(crate) fn pro_tx_hash(&self) -> Identifier { + Identifier::new(self.pro_tx_hash) + } + + /// Signs `vote` with the voting key, broadcasts it and waits for the response. When + /// Platform refused the voting key, the voter identity is fetched to say why. + pub(crate) async fn put_vote(&self, sdk: &Sdk, vote: &Vote) -> Result<(), FFIError> { + let broadcast = vote + .put_to_platform_and_wait_for_response( + self.pro_tx_hash(), + &self.voting_key, + sdk, + &self.signer, + None, + ) + .await; + let Err(broadcast_error) = broadcast else { + return Ok(()); + }; + + // Only a signature failure about the voting key is worth explaining. A closed poll, a + // fee failure or a transport error says nothing about the identity, and diagnosing + // those would let an absent voter identity pass for their cause. + if !is_voting_key_failure(&broadcast_error) { + return Err(FFIError::from(broadcast_error)); + } + + let problem = diagnose_voting_key_failure( + sdk, + ProTxHash::from_byte_array(self.pro_tx_hash), + &self.voting_address, + ) + .await; + + // When the identity and its key both check out, the signature failure was about + // something else on the key path: keep the original error. + Err(problem + .map(describe_voting_key_problem) + .unwrap_or_else(|| FFIError::from(broadcast_error))) + } +} + +/// The pro_tx_hash at `wire_order`, 32 bytes in wire order (see the module docs), as Platform +/// names the masternode: the bytes reversed. +/// +/// # Safety +/// `wire_order` must be null or point to 32 readable bytes for the duration of the call. +pub(crate) unsafe fn pro_tx_hash_from_wire_order( + wire_order: *const u8, +) -> Result { + if wire_order.is_null() { + return Err(invalid("voter_pro_tx_hash is null")); + } + let mut pro_tx_hash = [0u8; 32]; + pro_tx_hash.copy_from_slice(std::slice::from_raw_parts(wire_order, 32)); + pro_tx_hash.reverse(); + Ok(Identifier::new(pro_tx_hash)) +} + +fn invalid(message: &str) -> FFIError { + FFIError::InvalidParameter(message.to_string()) +} + +/// Renders a [`VotingKeyProblem`] as the message a host shows. +/// +/// rs-sdk reports the fact; phrasing it is a binding concern, so the sentence lives here +/// rather than in the SDK where every caller would inherit it. +fn describe_voting_key_problem(problem: VotingKeyProblem) -> FFIError { + match problem { + VotingKeyProblem::NoVoterIdentity { + pro_tx_hash, + expected_voter_identity, + } => FFIError::InvalidParameter(format!( + "No voting identity exists on Platform for masternode {} with this voting key \ + (expected voter identity {}). Either the voting key does not match the \ + masternode's registered voting address, or Platform has not created the \ + voter identity yet.", + Identifier::new(pro_tx_hash.to_byte_array()), + expected_voter_identity + )), + VotingKeyProblem::NoUsableVotingKey { voter_identity } => { + FFIError::InvalidParameter(format!( + "Voter identity {} has no enabled ECDSA_HASH160 voting key matching this \ + private key. The masternode's voting key may have been rotated.", + voter_identity + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::identity::signer::Signer; + + #[test] + fn should_reverse_the_wire_order_pro_tx_hash() { + let mut wire_order = [0u8; 32]; + for (index, byte) in wire_order.iter_mut().enumerate() { + *byte = index as u8; + } + let pro_tx_hash = + unsafe { pro_tx_hash_from_wire_order(wire_order.as_ptr()) }.expect("32 bytes"); + let mut reversed = wire_order; + reversed.reverse(); + assert_eq!(pro_tx_hash, Identifier::new(reversed)); + } + + #[test] + fn should_refuse_a_null_pro_tx_hash_or_voting_key() { + let key = [2u8; 32]; + let pro_tx_hash = [1u8; 32]; + for (pro_tx_hash, voting_private_key) in [ + (std::ptr::null(), key.as_ptr()), + (pro_tx_hash.as_ptr(), std::ptr::null()), + ] { + let error = unsafe { + MasternodeVoter::from_raw(pro_tx_hash, voting_private_key, FFINetwork::Testnet) + } + .err() + .expect("refused"); + assert!(matches!(error, FFIError::InvalidParameter(_)), "{error:?}"); + } + } + + #[test] + fn should_refuse_a_voting_key_that_is_not_a_secret_key() { + let pro_tx_hash = [1u8; 32]; + let zero_key = [0u8; 32]; + let error = unsafe { + MasternodeVoter::from_raw(pro_tx_hash.as_ptr(), zero_key.as_ptr(), FFINetwork::Testnet) + } + .err() + .expect("a zero scalar is not a secret key"); + assert!(matches!(error, FFIError::InvalidParameter(_)), "{error:?}"); + } + + /// The signer and the voting key both come from the private key, so the signer signs with + /// the key the vote names. + #[test] + fn should_derive_a_voting_key_the_signer_signs_with() { + let pro_tx_hash = [1u8; 32]; + let key = [2u8; 32]; + let voter = unsafe { + MasternodeVoter::from_raw(pro_tx_hash.as_ptr(), key.as_ptr(), FFINetwork::Testnet) + } + .expect("a valid key"); + assert_eq!(voter.voting_key.id(), 0); + assert_eq!(voter.voting_key.data().as_slice(), &voter.voting_address); + assert!(voter.signer.can_sign_with(&voter.voting_key)); + let mut reversed = pro_tx_hash; + reversed.reverse(); + assert_eq!(voter.pro_tx_hash(), Identifier::new(reversed)); + } + + // ---- describe_voting_key_problem ---------------------------------------- + // + // Selection, the failure gate and the diagnosis itself live in rs-sdk + // (`platform::transition::masternode_vote_keys`) and are tested there. What remains here is + // the one thing this layer owns: turning the SDK's typed fact into the sentence a host + // shows. + + #[test] + fn missing_identity_message_names_both_identifiers() { + let pro_tx = ProTxHash::from_byte_array([1u8; 32]); + let expected = Identifier::new([3u8; 32]); + let msg = format!( + "{:?}", + describe_voting_key_problem(VotingKeyProblem::NoVoterIdentity { + pro_tx_hash: pro_tx, + expected_voter_identity: expected, + }) + ); + + assert!(msg.contains(&format!("{}", Identifier::new(pro_tx.to_byte_array())))); + assert!(msg.contains(&format!("{}", expected))); + // The message crosses the FFI boundary verbatim and is shown to users, so it must + // read as prose: an earlier version rendered with blank gaps from an unescaped + // multi-line literal. + assert!(!msg.contains(" "), "message has whitespace runs: {msg}"); + } + + #[test] + fn rotated_key_message_names_the_identity_and_the_cause() { + let voter = Identifier::new([5u8; 32]); + let msg = format!( + "{:?}", + describe_voting_key_problem(VotingKeyProblem::NoUsableVotingKey { + voter_identity: voter, + }) + ); + + assert!(msg.contains(&format!("{}", voter))); + assert!(msg.contains("rotated")); + assert!(!msg.contains(" "), "message has whitespace runs: {msg}"); + } +} diff --git a/packages/rs-sdk-ffi/src/voting/mod.rs b/packages/rs-sdk-ffi/src/voting/mod.rs index f6736c90d62..cdeacac221e 100644 --- a/packages/rs-sdk-ffi/src/voting/mod.rs +++ b/packages/rs-sdk-ffi/src/voting/mod.rs @@ -1,4 +1,5 @@ // Voting-related modules +pub(crate) mod masternode_vote; mod queries; // Re-export all public functions diff --git a/packages/rs-sdk-ffi/src/voting/queries/vote_polls_by_end_date.rs b/packages/rs-sdk-ffi/src/voting/queries/vote_polls_by_end_date.rs index 29e552930de..f45ad982c61 100644 --- a/packages/rs-sdk-ffi/src/voting/queries/vote_polls_by_end_date.rs +++ b/packages/rs-sdk-ffi/src/voting/queries/vote_polls_by_end_date.rs @@ -1,8 +1,10 @@ use crate::types::SDKHandle; use crate::{DashSDKError, DashSDKErrorCode, DashSDKResult, DashSDKResultDataType}; +use dash_sdk::dpp::serialization::JsonConvertible; use dash_sdk::dpp::voting::vote_polls::VotePoll; use dash_sdk::drive::query::VotePollsByEndDateDriveQuery; use dash_sdk::platform::FetchMany; +use dash_sdk::query_types::VotePollsGroupedByTimestamp; use std::ffi::{c_void, CString}; /// Fetches vote polls by end date @@ -18,7 +20,18 @@ use std::ffi::{c_void, CString}; /// * `ascending` - Whether to order results in ascending order /// /// # Returns -/// * JSON array of vote polls grouped by timestamp or null if not found +/// * JSON array of the vote polls grouped by end date, or null when there is none: +/// `[{"timestamp": , "vote_polls": [, ...]}, ...]`. Each vote poll +/// is its canonical JSON, tagged with `$type`: +/// - a contested resource poll: +/// `{"$type": "contestedDocumentResourceVotePoll", "contractId": "", +/// "documentTypeName": "domain", "indexName": "parentNameAndLabel", +/// "indexValues": ["dash", "alice"]}`; +/// - a yes/no poll (protocol version 15), such as a decision proposal's: +/// `{"$type": "yesNoVotePoll", "purpose": {"$type": "decisionProposal", +/// "documentId": ""}, "supermajorityNumerator": 60, +/// "supermajorityDenominator": 100, "minimumVotingPower": {"$type": "fractionOfTotal", +/// "numerator": 10, "denominator": 100, "rounding": "up"}}`. /// * Error message if operation fails /// /// # Safety @@ -127,39 +140,54 @@ fn get_vote_polls_by_end_date( }; match VotePoll::fetch_many(&sdk, query).await { - Ok(vote_polls_grouped) => { - if vote_polls_grouped.0.is_empty() { - return Ok(None); - } - - let grouped_json: Vec = vote_polls_grouped - .0 - .iter() - .map(|(timestamp, vote_polls)| { - let polls_json: Vec = vote_polls - .iter() - .map(|_poll| format!(r#"{{"end_time":{}}}"#, timestamp)) - .collect(); - - format!( - r#"{{"timestamp":{},"vote_polls":[{}]}}"#, - timestamp, - polls_json.join(",") - ) - }) - .collect(); - - Ok(Some(format!("[{}]", grouped_json.join(",")))) - } + Ok(vote_polls_grouped) => vote_polls_by_end_date_json(&vote_polls_grouped), Err(e) => Err(format!("Failed to fetch vote polls by end date: {}", e)), } }) } +/// The JSON `dash_sdk_voting_get_vote_polls_by_end_date` returns for `grouped`, `None` when +/// it holds no end date. Each poll is written whole, as its canonical JSON, so a yes/no poll +/// keeps its purpose (a decision proposal's names the proposal document) and its rule. +fn vote_polls_by_end_date_json( + grouped: &VotePollsGroupedByTimestamp, +) -> Result, String> { + if grouped.0.is_empty() { + return Ok(None); + } + let groups = grouped + .0 + .iter() + .map(|(timestamp, vote_polls)| { + let vote_polls = vote_polls + .iter() + .map(|vote_poll| { + vote_poll + .to_json() + .map_err(|e| format!("Failed to write vote poll {vote_poll} as JSON: {e}")) + }) + .collect::, String>>()?; + Ok(serde_json::json!({ + "timestamp": timestamp, + "vote_polls": vote_polls, + })) + }) + .collect::, String>>()?; + serde_json::to_string(&groups) + .map(Some) + .map_err(|e| format!("Failed to serialize vote polls: {e}")) +} + #[cfg(test)] mod tests { use super::*; use crate::test_utils::test_utils::create_mock_sdk_handle; + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use dash_sdk::dpp::platform_value::{Identifier, Value}; + use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dash_sdk::dpp::voting::vote_polls::yes_no_vote_poll::{ + VotingPowerRounding, YesNoMinimumVotingPower, YesNoVotePoll, YesNoVotePollPurpose, + }; #[test] fn test_get_vote_polls_by_end_date_null_handle() { @@ -178,6 +206,81 @@ mod tests { } } + /// A yes/no poll keeps its purpose and rule, and a contested poll its index values, where + /// each used to be written as its end date alone. + #[test] + fn should_write_each_vote_poll_whole_with_its_kind() { + let proposal_id = Identifier::new([7; 32]); + let contested = + VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { + contract_id: Identifier::new([1; 32]), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text("alice".to_string()), + ], + }); + let yes_no = VotePoll::YesNoVotePoll(YesNoVotePoll { + purpose: YesNoVotePollPurpose::DecisionProposal { + document_id: proposal_id, + }, + supermajority_numerator: 60, + supermajority_denominator: 100, + minimum_voting_power: YesNoMinimumVotingPower::FractionOfTotal { + numerator: 10, + denominator: 100, + rounding: VotingPowerRounding::Up, + }, + }); + let grouped = + VotePollsGroupedByTimestamp(vec![(1_000, vec![contested]), (2_000, vec![yes_no])]); + + let json: serde_json::Value = serde_json::from_str( + &vote_polls_by_end_date_json(&grouped) + .expect("serializes") + .expect("not empty"), + ) + .expect("JSON"); + assert_eq!( + json, + serde_json::json!([ + { + "timestamp": 1_000, + "vote_polls": [{ + "$type": "contestedDocumentResourceVotePoll", + "contractId": Identifier::new([1; 32]).to_string(Encoding::Base58), + "documentTypeName": "domain", + "indexName": "parentNameAndLabel", + "indexValues": ["dash", "alice"], + }], + }, + { + "timestamp": 2_000, + "vote_polls": [{ + "$type": "yesNoVotePoll", + "purpose": { + "$type": "decisionProposal", + "documentId": proposal_id.to_string(Encoding::Base58), + }, + "supermajorityNumerator": 60, + "supermajorityDenominator": 100, + "minimumVotingPower": { + "$type": "fractionOfTotal", + "numerator": 10, + "denominator": 100, + "rounding": "up", + }, + }], + }, + ]) + ); + assert_eq!( + vote_polls_by_end_date_json(&VotePollsGroupedByTimestamp(vec![])), + Ok(None) + ); + } + #[test] fn test_get_vote_polls_by_end_date() { let handle = create_mock_sdk_handle(); diff --git a/packages/rs-unified-sdk-jni/src/decision_proposals.rs b/packages/rs-unified-sdk-jni/src/decision_proposals.rs new file mode 100644 index 00000000000..678b9a29c40 --- /dev/null +++ b/packages/rs-unified-sdk-jni/src/decision_proposals.rs @@ -0,0 +1,779 @@ +//! JNI exports for decision proposals (protocol version 15): yes/no questions any identity +//! puts to the masternodes, as `proposal` documents of the decision proposals system +//! contract. Each export marshals ONE `dash_sdk_decision_proposal_*` function of +//! `rs-sdk-ffi` (or the document free its handles need): it reads the JVM arguments, calls +//! the C function, copies the result into a JVM value and frees every Rust allocation the +//! result carried before returning. +//! +//! Creating and editing a proposal are sequences of these calls, run by Kotlin +//! (`voting.DecisionProposalTransactions`), never stitched together here: build (or fetch and +//! edit) a document, read its properties JSON, free the document handles, then put the +//! properties through the platform wallet's document create or replace +//! (`TransactionsNative.documentCreate` / `documentReplace`). Document handles cross as +//! `jlong` and are released with `documentFree`. +//! +//! Errors follow the rs-sdk-ffi convention of `queries.rs`: a `DashSDKResult` error throws +//! `DashSDKException` through [`crate::results::take_error`]. An argument the JVM value +//! cannot carry into the C type (a null required string, a byte array of the wrong length, +//! a number out of the C type's range) throws code 1 (`InvalidParameter`) before any C call; +//! everything rs-sdk-ffi and rs-sdk check is left to them. +//! +//! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DecisionProposalsNative`. + +use crate::results::{take_error, unwrap_handle, unwrap_string}; +use crate::support::{guard, net_from_ord, throw_sdk_exception}; +use jni::objects::{JByteArray, JClass, JString, JValue}; +use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject, jstring}; +use jni::JNIEnv; +use rs_sdk_ffi::{ + dash_sdk_decision_proposal_build, dash_sdk_decision_proposal_build_edit, + dash_sdk_decision_proposal_cast_vote, dash_sdk_decision_proposal_contract_id, + dash_sdk_decision_proposal_contract_id_bytes, dash_sdk_decision_proposal_document_type_name, + dash_sdk_decision_proposal_fetch, dash_sdk_decision_proposal_fetch_document, + dash_sdk_decision_proposal_get_masternode_vote, dash_sdk_decision_proposal_get_vote_poll_id, + dash_sdk_decision_proposal_get_vote_poll_state, dash_sdk_decision_proposal_list_by_created_at, + dash_sdk_decision_proposal_list_by_end_date, dash_sdk_decision_proposal_list_by_owner, + dash_sdk_decision_proposal_properties_json, dash_sdk_document_create_result_free, + dash_sdk_document_handle_destroy, DashSDKDecisionProposalEdit, DashSDKDecisionProposalParams, + DashSDKDocumentCreateResult, DashSDKResult, DocumentHandle, SDKHandle, +}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +/// JNI-internal name of the Kotlin class `build` returns. +const BUILD_RESULT_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DecisionProposalBuildNativeResult"; + +/// Constructor descriptor of [`BUILD_RESULT_CLASS`]: `(documentHandle: Long, entropy: ByteArray)`. +const BUILD_RESULT_CONSTRUCTOR: &str = "(J[B)V"; + +// ── Marshalling helpers ────────────────────────────────────────────────── + +/// Read a nullable JVM string into an owned `CString`. +/// +/// `Some(None)` for a null string, `Some(Some(_))` for a string, and `None` after throwing +/// for a string JNI cannot read or one holding a NUL byte (the caller bails out). An +/// unreadable string is never mistaken for an absent one. +fn read_optional_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option> { + if s.is_null() { + return Some(None); + } + let raw: String = match env.get_string(s) { + Ok(js) => js.into(), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} string was invalid")); + return None; + } + }; + match CString::new(raw) { + Ok(c) => Some(Some(c)), + Err(_) => { + throw_sdk_exception(env, 1, &format!("{field} contained an interior NUL")); + None + } + } +} + +/// Read a required JVM string into an owned `CString`; throws and returns `None` for a null +/// or unreadable string. +fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { + match read_optional_cstring(env, s, field)? { + Some(c) => Some(c), + None => { + throw_sdk_exception(env, 1, &format!("{field} was null")); + None + } + } +} + +/// The pointer the C functions take for an optional string: null for `None`. +fn c_ptr(value: &Option) -> *const c_char { + value.as_ref().map_or(ptr::null(), |s| s.as_ptr()) +} + +/// Read a required 32-byte array; throws and returns `None` for a null array or any other +/// length (the C functions read exactly 32 bytes through the pointer). +fn read_bytes32(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Option<[u8; 32]> { + let bytes = match env.convert_byte_array(arr) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was null/invalid")); + return None; + } + }; + match <[u8; 32]>::try_from(bytes.as_slice()) { + Ok(value) => Some(value), + Err(_) => { + throw_sdk_exception( + env, + 1, + &format!("{field} must be 32 bytes, got {}", bytes.len()), + ); + None + } + } +} + +/// Secret-key sibling of [`read_bytes32`]: the returned buffer is scrubbed on drop and the +/// intermediate JNI copy is zeroized before it is dropped. For the voting private key only. +fn read_key32_zeroizing( + env: &mut JNIEnv, + arr: &JByteArray, + field: &str, +) -> Option> { + use zeroize::Zeroize; + + let mut bytes = match env.convert_byte_array(arr) { + Ok(b) => b, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was null/invalid")); + return None; + } + }; + if bytes.len() != 32 { + let len = bytes.len(); + bytes.zeroize(); + throw_sdk_exception(env, 1, &format!("{field} must be 32 bytes, got {len}")); + return None; + } + let mut key = zeroize::Zeroizing::new([0u8; 32]); + key.copy_from_slice(&bytes); + bytes.zeroize(); + Some(key) +} + +/// `value` as the unsigned C type `T`; throws and returns `None` for a value `T` cannot +/// hold (negative, or too large) instead of wrapping it into a different, valid one. +fn to_unsigned>(env: &mut JNIEnv, value: i64, field: &str) -> Option { + match T::try_from(value) { + Ok(converted) => Some(converted), + Err(_) => { + throw_sdk_exception(env, 1, &format!("{field} is out of range: {value}")); + None + } + } +} + +/// The JVM string a result carries (its Rust string freed), or null for no data or after +/// throwing. +/// +/// # Safety +/// `result` must be a `DashSDKResult` freshly returned by an rs-sdk-ffi function whose +/// success payload is a `char*` or no data. +unsafe fn string_or_null(env: &mut JNIEnv, result: DashSDKResult) -> jstring { + unwrap_string(env, result) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +/// A JVM copy of a static C string owned by rs-sdk-ffi, which is never freed. +/// +/// # Safety +/// `value` must point to a NUL-terminated string that lives for the whole program. +unsafe fn static_string(env: &mut JNIEnv, value: *const c_char) -> jstring { + let value = CStr::from_ptr(value).to_string_lossy(); + env.new_string(value) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) +} + +/// Frees a document handle on drop unless disarmed: it holds a handle from the C call that +/// returned it until the JVM value carrying it to Kotlin exists, so no failure in between +/// leaks it. +struct DocumentHandleGuard(*mut DocumentHandle); + +impl DocumentHandleGuard { + /// Hands the handle on; the guard no longer frees it. + fn disarm(&mut self) -> *mut DocumentHandle { + std::mem::replace(&mut self.0, ptr::null_mut()) + } +} + +impl Drop for DocumentHandleGuard { + fn drop(&mut self) { + // SAFETY: the handle came from rs-sdk-ffi and nothing else owns it; null is a no-op. + unsafe { dash_sdk_document_handle_destroy(self.0) }; + } +} + +// ── Constants ──────────────────────────────────────────────────────────── + +/// The id of the decision proposals system contract, base58 +/// (`dash_sdk_decision_proposal_contract_id`): the `dataContractId` `buildEdit` takes. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_contractId( + mut env: JNIEnv, + _class: JClass, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| unsafe { + static_string(env, dash_sdk_decision_proposal_contract_id()) + }) +} + +/// The 32 bytes of the decision proposals contract id +/// (`dash_sdk_decision_proposal_contract_id_bytes`): the `contractId` the platform wallet's +/// document create and replace take. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_contractIdBytes( + mut env: JNIEnv, + _class: JClass, +) -> jbyteArray { + guard(&mut env, ptr::null_mut(), |env| { + let mut contract_id = [0u8; 32]; + // SAFETY: `contract_id` is 32 writable bytes. + if !unsafe { dash_sdk_decision_proposal_contract_id_bytes(contract_id.as_mut_ptr()) } { + throw_sdk_exception( + env, + 99, + "the decision proposals contract id was not written", + ); + return ptr::null_mut(); + } + env.byte_array_from_slice(&contract_id) + .map(|a| a.into_raw()) + .unwrap_or(ptr::null_mut()) + }) +} + +/// The document type name of a proposal (`dash_sdk_decision_proposal_document_type_name`). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_documentTypeName( + mut env: JNIEnv, + _class: JClass, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| unsafe { + static_string(env, dash_sdk_decision_proposal_document_type_name()) + }) +} + +// ── Create ─────────────────────────────────────────────────────────────── + +/// Build a decision proposal of `ownerIdentityId` (`dash_sdk_decision_proposal_build`). +/// Nothing is broadcast. +/// +/// Returns a `DecisionProposalBuildNativeResult(documentHandle, entropy)`: the C result +/// struct is freed here with `dash_sdk_document_create_result_free`, which leaves the +/// document alive, and the document handle passes to Kotlin, which frees it with +/// `documentFree`. If the result object cannot be made, the handle is freed here before +/// throwing. Null after throwing. +/// +/// `url` may be null (no url). `passPercent` and `minimumTurnoutPercent` must fit a `u8` and +/// `endDateMs` a `u64`; the limits on them are Platform's. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_build( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + owner_identity_id: JString, + title: JString, + description: JString, + url: JString, + pass_percent: jint, + minimum_turnout_percent: jint, + end_date_ms: jlong, +) -> jobject { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_cstring(env, &owner_identity_id, "ownerIdentityId") else { + return ptr::null_mut(); + }; + let Some(title) = read_cstring(env, &title, "title") else { + return ptr::null_mut(); + }; + let Some(description) = read_cstring(env, &description, "description") else { + return ptr::null_mut(); + }; + let Some(url) = read_optional_cstring(env, &url, "url") else { + return ptr::null_mut(); + }; + let Some(pass_percent) = to_unsigned(env, pass_percent.into(), "passPercent") else { + return ptr::null_mut(); + }; + let Some(minimum_turnout_percent) = + to_unsigned(env, minimum_turnout_percent.into(), "minimumTurnoutPercent") + else { + return ptr::null_mut(); + }; + let Some(end_date_ms) = to_unsigned(env, end_date_ms, "endDateMs") else { + return ptr::null_mut(); + }; + + let params = DashSDKDecisionProposalParams { + title: title.as_ptr(), + description: description.as_ptr(), + url: c_ptr(&url), + pass_percent, + minimum_turnout_percent, + end_date_ms, + }; + let result = unsafe { + dash_sdk_decision_proposal_build(sdk as *const SDKHandle, owner.as_ptr(), ¶ms) + }; + unsafe { build_result_object(env, result) } + }) +} + +/// The `DecisionProposalBuildNativeResult` for the result of +/// `dash_sdk_decision_proposal_build`, whose payload is a `DashSDKDocumentCreateResult`. +/// The struct is freed; the document handle is handed to Kotlin, or freed when the object +/// cannot be made. Null after throwing. +/// +/// # Safety +/// `result` must be freshly returned by `dash_sdk_decision_proposal_build`. +unsafe fn build_result_object(env: &mut JNIEnv, result: DashSDKResult) -> jobject { + if take_error(env, &result) { + return ptr::null_mut(); + } + if result.data.is_null() { + throw_sdk_exception( + env, + 99, + "decision proposal build returned success but no document", + ); + return ptr::null_mut(); + } + let created = result.data as *mut DashSDKDocumentCreateResult; + // Take the handle into the guard before the struct is freed, so it is never unowned. + let mut document = DocumentHandleGuard((*created).document_handle); + let entropy = (*created).entropy; + // Frees the struct only; the document stays alive in the guard. + dash_sdk_document_create_result_free(created); + + let Ok(entropy) = env.byte_array_from_slice(&entropy) else { + let _ = env.exception_clear(); + throw_sdk_exception( + env, + 99, + "decision proposal entropy byte[] allocation failed", + ); + return ptr::null_mut(); + }; + let object = env.new_object( + BUILD_RESULT_CLASS, + BUILD_RESULT_CONSTRUCTOR, + &[ + JValue::Long(document.0 as jlong), + JValue::Object(entropy.as_ref()), + ], + ); + match object { + Ok(object) => { + document.disarm(); + object.into_raw() + } + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "decision proposal build result allocation failed"); + ptr::null_mut() + } + } +} + +/// The properties of a proposal document as a JSON object +/// (`dash_sdk_decision_proposal_properties_json`): the `propertiesJson` the platform wallet's +/// document create (for a document from `build`) and replace (for one from `buildEdit`) +/// take. The caller still owns and frees `document`. Null after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_propertiesJson( + mut env: JNIEnv, + _class: JClass, + document: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| unsafe { + let result = dash_sdk_decision_proposal_properties_json(document as *const DocumentHandle); + string_or_null(env, result) + }) +} + +// ── Edit ───────────────────────────────────────────────────────────────── + +/// Fetch a decision proposal as a document handle +/// (`dash_sdk_decision_proposal_fetch_document`), the stored proposal `buildEdit` edits. +/// Returns 0 when there is no such proposal, and after throwing. Free a non-zero handle with +/// `documentFree`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_fetchDocument( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + proposal_id: JString, +) -> jlong { + guard(&mut env, 0, |env| { + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return 0; + }; + let result = unsafe { + dash_sdk_decision_proposal_fetch_document(sdk as *const SDKHandle, id.as_ptr()) + }; + unsafe { unwrap_handle(env, result) } + }) +} + +/// A copy of the stored proposal `proposal` with new text and its next revision +/// (`dash_sdk_decision_proposal_build_edit`). Pure: no network. `title`, `description` and +/// `url` null keep their value; `removeUrl` removes the url. Returns a new document handle, +/// 0 after throwing; the caller still owns and frees `proposal`, and frees the new handle +/// with `documentFree`. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_buildEdit( + mut env: JNIEnv, + _class: JClass, + proposal: jlong, + data_contract_id: JString, + document_type_name: JString, + title: JString, + description: JString, + url: JString, + remove_url: jboolean, +) -> jlong { + guard(&mut env, 0, |env| { + let Some(contract_id) = read_cstring(env, &data_contract_id, "dataContractId") else { + return 0; + }; + let Some(type_name) = read_cstring(env, &document_type_name, "documentTypeName") else { + return 0; + }; + let Some(title) = read_optional_cstring(env, &title, "title") else { + return 0; + }; + let Some(description) = read_optional_cstring(env, &description, "description") else { + return 0; + }; + let Some(url) = read_optional_cstring(env, &url, "url") else { + return 0; + }; + + let edit = DashSDKDecisionProposalEdit { + title: c_ptr(&title), + description: c_ptr(&description), + url: c_ptr(&url), + remove_url: remove_url != 0, + }; + let result = unsafe { + dash_sdk_decision_proposal_build_edit( + proposal as *const DocumentHandle, + contract_id.as_ptr(), + type_name.as_ptr(), + &edit, + ) + }; + unsafe { unwrap_handle(env, result) } + }) +} + +/// Free a document handle from `build`, `fetchDocument` or `buildEdit` +/// (`dash_sdk_document_handle_destroy`, which `dash_sdk_document_free` aliases). Safe on 0. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_documentFree( + mut env: JNIEnv, + _class: JClass, + document: jlong, +) { + guard(&mut env, (), |_| unsafe { + dash_sdk_document_handle_destroy(document as *mut DocumentHandle) + }); +} + +// ── Reads ──────────────────────────────────────────────────────────────── + +/// A decision proposal by id as the proposal JSON object +/// (`dash_sdk_decision_proposal_fetch`); null when there is none, and after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_fetch( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + proposal_id: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_fetch(sdk as *const SDKHandle, id.as_ptr()); + string_or_null(env, result) + } + }) +} + +/// One page of the proposals by end date, closing soonest first +/// (`dash_sdk_decision_proposal_list_by_end_date`), as a JSON array of proposals. `from` is +/// included and `to` left out; 0 is no bound. `limit` 0 is Platform's cap of 100; +/// `startAfter` (base58) is null for the first page. Null after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_listByEndDate( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + end_date_from_ms: jlong, + end_date_to_ms: jlong, + limit: jint, + start_after: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(from) = to_unsigned(env, end_date_from_ms, "endDateFromMs") else { + return ptr::null_mut(); + }; + let Some(to) = to_unsigned(env, end_date_to_ms, "endDateToMs") else { + return ptr::null_mut(); + }; + let Some(limit) = to_unsigned(env, limit.into(), "limit") else { + return ptr::null_mut(); + }; + let Some(start_after) = read_optional_cstring(env, &start_after, "startAfter") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_list_by_end_date( + sdk as *const SDKHandle, + from, + to, + limit, + c_ptr(&start_after), + ); + string_or_null(env, result) + } + }) +} + +/// One page of an identity's proposals in filing order +/// (`dash_sdk_decision_proposal_list_by_owner`), as a JSON array of proposals. Paging as for +/// `listByEndDate`. Null after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_listByOwner( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + owner_id: JString, + limit: jint, + start_after: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_cstring(env, &owner_id, "ownerId") else { + return ptr::null_mut(); + }; + let Some(limit) = to_unsigned(env, limit.into(), "limit") else { + return ptr::null_mut(); + }; + let Some(start_after) = read_optional_cstring(env, &start_after, "startAfter") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_list_by_owner( + sdk as *const SDKHandle, + owner.as_ptr(), + limit, + c_ptr(&start_after), + ); + string_or_null(env, result) + } + }) +} + +/// One page of every proposal, newest first +/// (`dash_sdk_decision_proposal_list_by_created_at`), as a JSON array of proposals. Paging +/// as for `listByEndDate`. Null after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_listByCreatedAt( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + limit: jint, + start_after: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(limit) = to_unsigned(env, limit.into(), "limit") else { + return ptr::null_mut(); + }; + let Some(start_after) = read_optional_cstring(env, &start_after, "startAfter") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_list_by_created_at( + sdk as *const SDKHandle, + limit, + c_ptr(&start_after), + ); + string_or_null(env, result) + } + }) +} + +/// The id of a proposal's yes/no poll as a plain base58 string, not JSON +/// (`dash_sdk_decision_proposal_get_vote_poll_id`). Null after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_getVotePollId( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + proposal_id: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return ptr::null_mut(); + }; + unsafe { + let result = + dash_sdk_decision_proposal_get_vote_poll_id(sdk as *const SDKHandle, id.as_ptr()); + string_or_null(env, result) + } + }) +} + +/// The status and tallies of a proposal's yes/no poll as JSON +/// (`dash_sdk_decision_proposal_get_vote_poll_state`); null when the poll was never opened, +/// and after throwing. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_getVotePollState( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + proposal_id: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_get_vote_poll_state( + sdk as *const SDKHandle, + id.as_ptr(), + ); + string_or_null(env, result) + } + }) +} + +/// A masternode's answer on a proposal, `{"choice", "timesVoted"}` +/// (`dash_sdk_decision_proposal_get_masternode_vote`); null when it has not voted or the +/// poll finished, and after throwing. `voterProTxHash` is the 32-byte pro_tx_hash in wire +/// order, the same bytes `castVote` takes. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_getMasternodeVote( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + voter_pro_tx_hash: JByteArray, + proposal_id: JString, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(pro_tx_hash) = read_bytes32(env, &voter_pro_tx_hash, "voterProTxHash") else { + return ptr::null_mut(); + }; + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return ptr::null_mut(); + }; + unsafe { + let result = dash_sdk_decision_proposal_get_masternode_vote( + sdk as *const SDKHandle, + pro_tx_hash.as_ptr(), + id.as_ptr(), + ); + string_or_null(env, result) + } + }) +} + +// ── Vote ───────────────────────────────────────────────────────────────── + +/// Vote on a decision proposal for a masternode and wait for the response +/// (`dash_sdk_decision_proposal_cast_vote`). +/// +/// * `voteChoice` - `0` yes, `1` no, `2` abstain; rs-sdk-ffi refuses any other value. A +/// value outside `u8` is refused here rather than wrapped onto a valid answer. +/// * `voterProTxHash` / `votingPrivateKey` - each a 32-byte `byte[]`; the pro_tx_hash in wire +/// order, as for `castContestedResourceVote`. The key is read through a zeroizing buffer. +/// * `networkOrd` - `Network.ffiValue`, used for the signer's WIF encoding only. +/// +/// Returns nothing on success; throws on error. A vote from a wallet that is not a +/// masternode is refused by Platform (no voter identity), the expected outcome. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_castVote( + mut env: JNIEnv, + _class: JClass, + sdk: jlong, + proposal_id: JString, + vote_choice: jint, + voter_pro_tx_hash: JByteArray, + voting_private_key: JByteArray, + network_ord: jint, +) { + guard(&mut env, (), |env| { + let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { + return; + }; + let Some(choice) = to_unsigned(env, vote_choice.into(), "voteChoice") else { + return; + }; + let Some(pro_tx_hash) = read_bytes32(env, &voter_pro_tx_hash, "voterProTxHash") else { + return; + }; + let Some(voting_key) = read_key32_zeroizing(env, &voting_private_key, "votingPrivateKey") + else { + return; + }; + let result = unsafe { + dash_sdk_decision_proposal_cast_vote( + sdk as *const SDKHandle, + id.as_ptr(), + choice, + pro_tx_hash.as_ptr(), + voting_key.as_ptr(), + net_from_ord(network_ord), + ) + }; + // Success carries no payload; take_error throws and frees on error. + unsafe { take_error(env, &result) }; + }) +} + +#[cfg(test)] +mod tests { + use super::{BUILD_RESULT_CLASS, BUILD_RESULT_CONSTRUCTOR}; + + const DECISION_PROPOSALS_NATIVE_KT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt" + )); + + /// `build` constructs the Kotlin result by class name and descriptor, which only an + /// instrumented test would otherwise catch drifting: the class must sit where + /// [`BUILD_RESULT_CLASS`] says and take `(Long, ByteArray)` in that order. + #[test] + fn should_construct_the_build_result_kotlin_declares() { + let (package, class) = BUILD_RESULT_CLASS + .rsplit_once('/') + .expect("a JNI class name has a package"); + assert!( + DECISION_PROPOSALS_NATIVE_KT + .lines() + .any(|line| line.trim() == format!("package {}", package.replace('/', "."))), + "DecisionProposalsNative.kt is not in package {package}" + ); + let declaration = DECISION_PROPOSALS_NATIVE_KT + .split(&format!("class {class}(")) + .nth(1) + .expect("DecisionProposalsNative.kt declares the build result class"); + let parameters: Vec<&str> = declaration + .split(')') + .next() + .expect("the constructor has a parameter list") + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect(); + assert_eq!( + parameters, + [ + "@JvmField val documentHandle: Long,", + "@JvmField val entropy: ByteArray," + ] + ); + assert_eq!(BUILD_RESULT_CONSTRUCTOR, "(J[B)V"); + } +} diff --git a/packages/rs-unified-sdk-jni/src/lib.rs b/packages/rs-unified-sdk-jni/src/lib.rs index e955750016a..309bcf768b1 100644 --- a/packages/rs-unified-sdk-jni/src/lib.rs +++ b/packages/rs-unified-sdk-jni/src/lib.rs @@ -16,6 +16,7 @@ mod credits; mod dashpay; +mod decision_proposals; mod dpns_marketplace; mod events; mod funding; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift new file mode 100644 index 00000000000..920b0435105 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift @@ -0,0 +1,92 @@ +import Foundation + +// MARK: - Decision proposals (protocol version 15) +// +// A decision proposal is a `proposal` document of the decision proposals +// system contract, so it is created and edited through the platform wallet's +// document create and replace, the same path every other document takes: +// the Rust side signs through the `KeychainSigner` and the signing key never +// crosses into Swift logic. rs-sdk builds the properties +// (`SDK.buildDecisionProposal`, `DecisionProposalDocument.edited`); these +// wrappers pass them on with the contract id and type name the FFI names. + +extension ManagedPlatformWallet { + /// Create + broadcast the decision proposal `proposal`, built by + /// ``SDK/buildDecisionProposal(ownerIdentityId:params:)`` for + /// `ownerIdentityId`. Returns the proposal's 32-byte id and the confirmed + /// document's canonical JSON once Platform confirms the transition. + /// + /// Routes through ``createDocument(ownerIdentityId:contractId:documentType:propertiesJSON:signer:maxContestFund:)`` + /// with the proposal's ``DecisionProposalDocument/propertiesJSON()``. The + /// create states the decision proposal fund on top of the fees; a proposal + /// joins no contest, so no contest fund is stated. + /// + /// - Parameters: + /// - proposal: The proposal built for `ownerIdentityId`. + /// - ownerIdentityId: The proposer, the identity the proposal was built + /// for. + /// - signer: Signs the create; see `createDocument` for its lifetime + /// contract. + public func createDecisionProposal( + _ proposal: DecisionProposalDocument, + ownerIdentityId: Identifier, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + // `createDocument` reads exactly 32 bytes of the identifier. + guard ownerIdentityId.count == 32 else { + throw PlatformWalletError.invalidParameter("ownerIdentityId must be exactly 32 bytes") + } + let propertiesJSON = try proposal.propertiesJSON() + return try await createDocument( + ownerIdentityId: ownerIdentityId, + contractId: DecisionProposals.contractIdentifier, + documentType: DecisionProposals.documentTypeName, + propertiesJSON: propertiesJSON, + signer: signer + ) + } + + /// Replace + broadcast the decision proposal `proposalId` with the text of + /// `edited`, the stored proposal edited by + /// ``DecisionProposalDocument/edited(_:)``. Returns the proposal's 32-byte + /// id and the confirmed document's canonical JSON at its new revision. + /// + /// Routes through ``replaceDocument(ownerIdentityId:contractId:documentType:documentId:propertiesJSON:signingKeyId:signer:)`` + /// with the edited document's ``DecisionProposalDocument/propertiesJSON()``: + /// the wallet fetches the stored proposal, sets these properties and moves + /// it to the same next revision. Platform refuses the replace outside the + /// proposal's edit window. + /// + /// - Parameters: + /// - edited: The stored proposal with its new text. + /// - ownerIdentityId: The proposer. + /// - proposalId: The proposal's 32-byte id. + /// - signingKeyId: The owner's AUTHENTICATION + ECDSA key to sign with. + /// - signer: Signs the replace; see `replaceDocument` for its lifetime + /// contract. + public func replaceDecisionProposal( + _ edited: DecisionProposalDocument, + ownerIdentityId: Identifier, + proposalId: Identifier, + signingKeyId: UInt32, + signer: KeychainSigner + ) async throws -> (Identifier, String) { + // `replaceDocument` reads exactly 32 bytes of each identifier. + guard ownerIdentityId.count == 32 else { + throw PlatformWalletError.invalidParameter("ownerIdentityId must be exactly 32 bytes") + } + guard proposalId.count == 32 else { + throw PlatformWalletError.invalidParameter("proposalId must be exactly 32 bytes") + } + let propertiesJSON = try edited.propertiesJSON() + return try await replaceDocument( + ownerIdentityId: ownerIdentityId, + contractId: DecisionProposals.contractIdentifier, + documentType: DecisionProposals.documentTypeName, + documentId: proposalId, + propertiesJSON: propertiesJSON, + signingKeyId: signingKeyId, + signer: signer + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift new file mode 100644 index 00000000000..d5087fcaa3d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift @@ -0,0 +1,427 @@ +import Foundation +import DashSDKFFI + +// MARK: - Constants + +/// The decision proposals system contract (protocol version 15), as the +/// document functions name it. +/// +/// Read from `rs-sdk-ffi` (`dash_sdk_decision_proposal_contract_id`, +/// `dash_sdk_decision_proposal_contract_id_bytes`, +/// `dash_sdk_decision_proposal_document_type_name`) so no Swift copy of a +/// protocol constant exists. +public enum DecisionProposals { + /// Base58 id of the decision proposals system contract. + public static let contractId: String = { + // A static string owned by the FFI: copied, never freed. + String(cString: dash_sdk_decision_proposal_contract_id()) + }() + + /// The 32 bytes of the contract id, as the platform wallet's document + /// functions take it. + public static let contractIdentifier: Identifier = { + var bytes = [UInt8](repeating: 0, count: 32) + // Returns false only for a null pointer, which this buffer is not. + _ = bytes.withUnsafeMutableBufferPointer { buffer in + dash_sdk_decision_proposal_contract_id_bytes(buffer.baseAddress) + } + return Data(bytes) + }() + + /// The proposal document type name. + public static let documentTypeName: String = { + // A static string owned by the FFI: copied, never freed. + String(cString: dash_sdk_decision_proposal_document_type_name()) + }() +} + +// MARK: - Proposal + +/// A decision proposal: a yes/no question an identity puts to the +/// masternodes. Decoded from the JSON every decision proposal read returns +/// (`dash_sdk_decision_proposal_fetch` and the three listings). +public struct DecisionProposal: Decodable, Sendable, Equatable, Hashable, Identifiable { + /// The proposal's id, base58. + public let id: String + /// The proposer's identity id, base58. + public let ownerId: String + /// `nil` when the document does not carry it. + public let revision: UInt64? + /// Milliseconds since the Unix epoch; `nil` when the document does not + /// carry it. + public let createdAtMs: UInt64? + /// Milliseconds since the Unix epoch; `nil` when the document does not + /// carry it. + public let updatedAtMs: UInt64? + /// The question, in one line. + public let title: String + /// What a yes and a no mean. + public let description: String + /// Where the proposal is discussed; `nil` when it has none. + public let url: String? + /// The proposal passes when the yes voting power is strictly more than + /// this percent of the yes plus no voting power. + public let passPercent: UInt8 + /// The least yes plus no voting power the proposal needs, as a percent of + /// the masternode list's total voting power when it closes, rounded up. + public let minimumTurnoutPercent: UInt8 + /// When voting closes, in milliseconds since the Unix epoch. + public let endDateMs: UInt64 + + private enum CodingKeys: String, CodingKey { + case id + case ownerId + case revision + case createdAtMs = "createdAt" + case updatedAtMs = "updatedAt" + case title + case description + case url + case passPercent + case minimumTurnoutPercent + case endDateMs = "endDate" + } + + /// When voting closes. + public var endDate: Date { Date(millisecondsSince1970: endDateMs) } + /// When the proposal was created, if the document carries it. + public var createdAt: Date? { createdAtMs.map(Date.init(millisecondsSince1970:)) } + /// When the proposal was last edited, if the document carries it. + public var updatedAt: Date? { updatedAtMs.map(Date.init(millisecondsSince1970:)) } +} + +/// The question and rule of a new decision proposal +/// (`DashSDKDecisionProposalParams`). Platform judges every value when the +/// proposal is created; nothing is checked here. +public struct DecisionProposalParams: Sendable, Equatable { + /// The question, in one line. + public var title: String + /// What a yes and a no mean. + public var description: String + /// Where the proposal is discussed; `nil` for none. + public var url: String? + /// The proposal passes when the yes voting power is strictly more than + /// this percent of the yes plus no voting power. + public var passPercent: UInt8 + /// The least yes plus no voting power the proposal needs, as a percent of + /// the masternode list's total voting power when it closes. + public var minimumTurnoutPercent: UInt8 + /// When voting closes, in milliseconds since the Unix epoch. + public var endDateMs: UInt64 + + public init( + title: String, + description: String, + url: String? = nil, + passPercent: UInt8, + minimumTurnoutPercent: UInt8, + endDateMs: UInt64 + ) { + self.title = title + self.description = description + self.url = url + self.passPercent = passPercent + self.minimumTurnoutPercent = minimumTurnoutPercent + self.endDateMs = endDateMs + } + + /// The same, with the end date given as a `Date`. A date before the Unix + /// epoch is written as 0, which Platform refuses. + public init( + title: String, + description: String, + url: String? = nil, + passPercent: UInt8, + minimumTurnoutPercent: UInt8, + endDate: Date + ) { + self.init( + title: title, + description: description, + url: url, + passPercent: passPercent, + minimumTurnoutPercent: minimumTurnoutPercent, + endDateMs: endDate.millisecondsSince1970 + ) + } +} + +/// New text for a stored decision proposal (`DashSDKDecisionProposalEdit`). +/// A field left `nil` (or `.keep`) keeps its value. rs-sdk refuses an edit +/// that changes nothing; Platform accepts an edit only within its window +/// after the proposal was created, and never a change to the rule. +public struct DecisionProposalEdit: Sendable, Equatable { + /// What happens to the proposal's url. + public enum URLChange: Sendable, Equatable { + /// Keep the url as it is (or keep having none). + case keep + /// Set the url. + case set(String) + /// Remove the url. + case remove + } + + /// The new title; `nil` keeps it. + public var title: String? + /// The new description; `nil` keeps it. + public var description: String? + /// The url change. + public var url: URLChange + + public init(title: String? = nil, description: String? = nil, url: URLChange = .keep) { + self.title = title + self.description = description + self.url = url + } +} + +// MARK: - Vote choice + +/// A masternode's answer on a yes/no poll, as rs-dpp's +/// `YesNoAbstainVoteChoice`. +/// +/// Its raw value is the name the masternode-answer JSON writes (`"yes"`, +/// `"no"`, `"abstain"`); ``ffiTag`` is the `vote_choice` byte +/// `dash_sdk_decision_proposal_cast_vote` takes. +public enum YesNoAbstainVoteChoice: String, Sendable, Equatable, CaseIterable, Identifiable, Decodable { + case yes + case no + case abstain + + public var id: String { rawValue } + + /// The `vote_choice` byte passed across the FFI. The C parameter is a + /// plain `uint8_t` with no enum in the header, so the three values are + /// mirrored here and must agree with `YesNoVoteChoiceFFI` in + /// `packages/rs-sdk-ffi/src/decision_proposal/transitions.rs`: + /// `Yes = 0`, `No = 1`, `Abstain = 2`. + var ffiTag: UInt8 { + switch self { + case .yes: return 0 + case .no: return 1 + case .abstain: return 2 + } + } +} + +/// A masternode's answer on a decision proposal +/// (`dash_sdk_decision_proposal_get_masternode_vote`). +public struct MasternodeYesNoVote: Decodable, Sendable, Equatable { + /// The masternode's current answer. + public let choice: YesNoAbstainVoteChoice + /// How many times the masternode has voted on the poll, its first vote + /// included. + public let timesVoted: UInt16 +} + +// MARK: - Poll state + +/// A block, as the poll state writes it. +public struct PlatformBlockInfo: Decodable, Sendable, Equatable { + public let height: UInt64 + public let coreHeight: UInt32 + /// Block time, milliseconds since the Unix epoch. + public let timeMs: UInt64 + /// The epoch index. + public let epoch: UInt16 + + /// Block time. + public var time: Date { Date(millisecondsSince1970: timeMs) } +} + +/// Which way a fraction of the total voting power rounds to a whole voting +/// power (rs-dpp `VotingPowerRounding`). +public enum VotingPowerRounding: String, Decodable, Sendable, Equatable { + case down + case up + case downPlusOne + case upMinusOne +} + +/// The least yes plus no voting power a yes/no poll needs to pass (rs-dpp +/// `YesNoMinimumVotingPower`), tagged with `$type` in its JSON. +public enum YesNoMinimumVotingPower: Decodable, Sendable, Equatable { + /// A fixed voting power. + case absolute(votingPower: UInt64) + /// A share of the total voting power of the masternode list in the block + /// that closes the poll, rounded as given. + case fractionOfTotal(numerator: UInt8, denominator: UInt8, rounding: VotingPowerRounding) + + private enum CodingKeys: String, CodingKey { + case type = "$type" + case votingPower + case numerator + case denominator + case rounding + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "absolute": + self = .absolute(votingPower: try container.decode(UInt64.self, forKey: .votingPower)) + case "fractionOfTotal": + self = .fractionOfTotal( + numerator: try container.decode(UInt8.self, forKey: .numerator), + denominator: try container.decode(UInt8.self, forKey: .denominator), + rounding: try container.decode(VotingPowerRounding.self, forKey: .rounding) + ) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown minimum voting power type \(type)" + ) + } + } +} + +/// What a yes/no poll decides (rs-dpp `YesNoVotePollPurpose`), tagged with +/// `$type` in its JSON. +public enum YesNoVotePollPurpose: Decodable, Sendable, Equatable { + /// The poll a decision proposal opens; `documentId` is the proposal's id, + /// base58. + case decisionProposal(documentId: String) + + private enum CodingKeys: String, CodingKey { + case type = "$type" + case documentId + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "decisionProposal": + self = .decisionProposal(documentId: try container.decode(String.self, forKey: .documentId)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown yes/no vote poll purpose \(type)" + ) + } + } +} + +/// A yes/no poll as it was opened, rule included (rs-dpp `YesNoVotePoll`). +public struct YesNoVotePoll: Decodable, Sendable, Equatable { + public let purpose: YesNoVotePollPurpose + /// With ``supermajorityDenominator``, the share of yes plus no the yes + /// voting power must strictly exceed. + public let supermajorityNumerator: UInt8 + public let supermajorityDenominator: UInt8 + public let minimumVotingPower: YesNoMinimumVotingPower +} + +/// The record a finished yes/no poll keeps (rs-dpp `YesNoVotePollResult`). +public struct YesNoVotePollResult: Decodable, Sendable, Equatable { + /// Whether the poll passed. + public let passed: Bool + /// The yes voting power at the end. + public let yesVotingPower: UInt64 + /// The no voting power at the end. + public let noVotingPower: UInt64 + /// The abstaining voting power at the end. + public let abstainVotingPower: UInt64 + /// The yes plus no voting power the poll needed, resolved in the block + /// that closed it. + public let requiredVotingPower: UInt64 + /// The block the poll opened in. + public let startBlock: PlatformBlockInfo + /// The block the poll was closed in. + public let finalizationBlock: PlatformBlockInfo +} + +/// The status and tallies of a yes/no poll +/// (`dash_sdk_decision_proposal_get_vote_poll_state`). +public struct YesNoVotePollState: Decodable, Sendable, Equatable { + /// Whether the poll is open or has finished. + public enum Status: Sendable, Equatable { + /// Voting is open; the poll opened in `startBlock`. + case started(startBlock: PlatformBlockInfo) + /// The poll has finished; its record. + case finished(YesNoVotePollResult) + } + + /// The poll as it was opened, rule included. + public let votePoll: YesNoVotePoll + public let status: Status + /// The yes voting power so far. 0 once the poll has finished, as its + /// votes are removed; read ``Status/finished(_:)`` then. + public let yesVotingPower: UInt64 + /// The no voting power so far; 0 once the poll has finished. + public let noVotingPower: UInt64 + /// The abstaining voting power so far; 0 once the poll has finished. + public let abstainVotingPower: UInt64 + + /// The poll's record once it has finished, `nil` while it is open. + public var result: YesNoVotePollResult? { + guard case .finished(let result) = status else { return nil } + return result + } + + private enum CodingKeys: String, CodingKey { + case votePoll + case status + case startBlock + case result + case yesVotingPower + case noVotingPower + case abstainVotingPower + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + votePoll = try container.decode(YesNoVotePoll.self, forKey: .votePoll) + yesVotingPower = try container.decode(UInt64.self, forKey: .yesVotingPower) + noVotingPower = try container.decode(UInt64.self, forKey: .noVotingPower) + abstainVotingPower = try container.decode(UInt64.self, forKey: .abstainVotingPower) + let status = try container.decode(String.self, forKey: .status) + switch status { + case "started": + self.status = .started( + startBlock: try container.decode(PlatformBlockInfo.self, forKey: .startBlock) + ) + case "finished": + self.status = .finished(try container.decode(YesNoVotePollResult.self, forKey: .result)) + default: + throw DecodingError.dataCorruptedError( + forKey: .status, + in: container, + debugDescription: "Unknown yes/no vote poll status \(status)" + ) + } + } +} + +// MARK: - JSON decoding + +/// Decodes the JSON the decision proposal FFI functions return. Shared by +/// the wrappers and the hermetic decoder tests, so both read the same way. +enum DecisionProposalJSON { + static func decode(_ type: T.Type, from json: String) throws -> T { + do { + return try JSONDecoder().decode(type, from: Data(json.utf8)) + } catch { + throw SDKError.serializationError("Failed to decode \(T.self): \(error)") + } + } +} + +// MARK: - Millisecond timestamps + +extension Date { + /// The date `milliseconds` after the Unix epoch. + init(millisecondsSince1970 milliseconds: UInt64) { + self.init(timeIntervalSince1970: TimeInterval(milliseconds) / 1000) + } + + /// Whole milliseconds since the Unix epoch, rounded down; 0 for a date + /// before the epoch. + var millisecondsSince1970: UInt64 { + let milliseconds = (timeIntervalSince1970 * 1000).rounded(.down) + return UInt64(exactly: milliseconds) ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift new file mode 100644 index 00000000000..7552fec09f2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift @@ -0,0 +1,455 @@ +import Foundation +import DashSDKFFI + +// MARK: - Decision proposals (protocol version 15) +// +// Thin bridges over the `dash_sdk_decision_proposal_*` functions of +// `rs-sdk-ffi`, which call `dash_sdk::platform::decision_proposals` and +// `dash_sdk::platform::yes_no_vote_polls`. rs-sdk decides everything: the +// proposal's properties, the fund the create states, which documents are +// proposals, refusing an edit that changes nothing, the next revision, the +// poll's id. These wrappers marshal arguments in and decode the JSON out. +// +// Every read and the vote park their thread inside Rust until DAPI answers, +// so they run on the shared Platform query queue +// (``SDK/performBlockingQuery(_:)``) and the caller only awaits. +// +// Creating and editing a proposal goes through the platform wallet, the same +// path the app creates and replaces every other document by: +// ``buildDecisionProposal(ownerIdentityId:params:)`` (or +// ``fetchDecisionProposalDocument(proposalId:)`` then +// ``DecisionProposalDocument/edited(_:)``) here, then +// `ManagedPlatformWallet.createDecisionProposal` / +// `ManagedPlatformWallet.replaceDecisionProposal`. + +/// A decision proposal document held by `rs-sdk-ffi`: one built for a create +/// (``SDK/buildDecisionProposal(ownerIdentityId:params:)``), a stored one +/// fetched for an edit (``SDK/fetchDecisionProposalDocument(proposalId:)``), +/// or an edited copy (``edited(_:)``). +/// +/// Owns its `DocumentHandle` and frees it on deinit. Both methods only read +/// the document, and it never changes after it is made, so the object may be +/// shared across threads. +public final class DecisionProposalDocument: @unchecked Sendable { + /// The `DocumentHandle *` this object owns. + private let handle: OpaquePointer + + /// Takes ownership of `handle`. + fileprivate init(handle: OpaquePointer) { + self.handle = handle + } + + deinit { + dash_sdk_document_free(handle) + } + + /// The document's own properties as the JSON object the platform + /// wallet's document create and replace take + /// (`dash_sdk_decision_proposal_properties_json`). A proposal without a + /// url has no `url` key. + public func propertiesJSON() throws -> String { + let result = dash_sdk_decision_proposal_properties_json(handle) + guard let json = try DecisionProposalFFI.consumeString(result) else { + throw SDKError.internalError("dash_sdk_decision_proposal_properties_json returned no data") + } + return json + } + + /// A copy of this proposal with the text `edit` sets, at its next revision + /// (`dash_sdk_decision_proposal_build_edit`). Pure: no network, and this + /// document is left as it is. + /// + /// rs-sdk refuses, as ``SDKError/invalidParameter(_:)``, an edit that + /// changes nothing and a document that is not a decision proposal. + public func edited(_ edit: DecisionProposalEdit) throws -> DecisionProposalDocument { + let url: String? + let removeURL: Bool + switch edit.url { + case .keep: + url = nil + removeURL = false + case .set(let value): + url = value + removeURL = false + case .remove: + url = nil + removeURL = true + } + + let result = withDecisionProposalCString(edit.title) { titlePtr in + withDecisionProposalCString(edit.description) { descriptionPtr in + withDecisionProposalCString(url) { urlPtr in + let ffiEdit = DashSDKDecisionProposalEdit( + title: titlePtr, + description: descriptionPtr, + url: urlPtr, + remove_url: removeURL + ) + return withUnsafePointer(to: ffiEdit) { editPtr in + // The contract id and type name are the FFI's own + // static strings, passed straight back. + dash_sdk_decision_proposal_build_edit( + handle, + dash_sdk_decision_proposal_contract_id(), + dash_sdk_decision_proposal_document_type_name(), + editPtr + ) + } + } + } + } + guard let edited = try DecisionProposalFFI.consumeDocument(result) else { + throw SDKError.internalError("dash_sdk_decision_proposal_build_edit returned no document") + } + return edited + } +} + +extension SDK { + + // MARK: - Reads + + /// The decision proposal `id` names, or `nil` when there is none + /// (`dash_sdk_decision_proposal_fetch`). + /// + /// - Parameter id: The proposal's id, base58. + public nonisolated func decisionProposal(id: String) async throws -> DecisionProposal? { + try await performBlockingQuery { handle in + let result = id.withCString { idPtr in + dash_sdk_decision_proposal_fetch(handle, idPtr) + } + return try DecisionProposalFFI.consumeString(result).map { + try DecisionProposalJSON.decode(DecisionProposal.self, from: $0) + } + } + } + + /// One page of the decision proposals whose end date is within the + /// bounds, closing soonest first (`dash_sdk_decision_proposal_list_by_end_date`). + /// + /// - Parameters: + /// - from: The earliest end date listed, included; `nil` for no lower + /// bound. The current time lists the open proposals. + /// - to: The end date the listing stops before; `nil` for no upper bound. + /// - limit: The most proposals to return; `nil` for Platform's cap. + /// - startAfter: The id (base58) of the last proposal of the previous + /// page; `nil` for the first page. + /// - Returns: The page; one shorter than `limit` is the last. + public nonisolated func decisionProposalsByEndDate( + from: Date? = nil, + to: Date? = nil, + limit: UInt32? = nil, + startAfter: String? = nil + ) async throws -> [DecisionProposal] { + // 0 is the FFI's "no bound". + let fromMs = from?.millisecondsSince1970 ?? 0 + let toMs = to?.millisecondsSince1970 ?? 0 + return try await performBlockingQuery { handle in + let result = withDecisionProposalCString(startAfter) { startAfterPtr in + dash_sdk_decision_proposal_list_by_end_date( + handle, fromMs, toMs, limit ?? 0, startAfterPtr) + } + return try DecisionProposalFFI.consumeListing( + result, function: "dash_sdk_decision_proposal_list_by_end_date") + } + } + + /// One page of an identity's decision proposals, in the order it filed + /// them (`dash_sdk_decision_proposal_list_by_owner`). + /// + /// - Parameters: + /// - ownerId: The proposer's identity id, base58. + /// - limit: The most proposals to return; `nil` for Platform's cap. + /// - startAfter: The id (base58) of the last proposal of the previous + /// page; `nil` for the first page. + public nonisolated func decisionProposalsByOwner( + ownerId: String, + limit: UInt32? = nil, + startAfter: String? = nil + ) async throws -> [DecisionProposal] { + try await performBlockingQuery { handle in + let result = ownerId.withCString { ownerPtr in + withDecisionProposalCString(startAfter) { startAfterPtr in + dash_sdk_decision_proposal_list_by_owner( + handle, ownerPtr, limit ?? 0, startAfterPtr) + } + } + return try DecisionProposalFFI.consumeListing( + result, function: "dash_sdk_decision_proposal_list_by_owner") + } + } + + /// One page of every decision proposal, newest first + /// (`dash_sdk_decision_proposal_list_by_created_at`). + /// + /// - Parameters: + /// - limit: The most proposals to return; `nil` for Platform's cap. + /// - startAfter: The id (base58) of the last proposal of the previous + /// page; `nil` for the first page. + public nonisolated func decisionProposalsByCreatedAt( + limit: UInt32? = nil, + startAfter: String? = nil + ) async throws -> [DecisionProposal] { + try await performBlockingQuery { handle in + let result = withDecisionProposalCString(startAfter) { startAfterPtr in + dash_sdk_decision_proposal_list_by_created_at(handle, limit ?? 0, startAfterPtr) + } + return try DecisionProposalFFI.consumeListing( + result, function: "dash_sdk_decision_proposal_list_by_created_at") + } + } + + /// The id (base58) of the yes/no poll a decision proposal opens, the id + /// the end-date listing of vote polls names it by + /// (`dash_sdk_decision_proposal_get_vote_poll_id`). + /// + /// - Parameter proposalId: The proposal's id, base58. + public nonisolated func decisionProposalVotePollId(proposalId: String) async throws -> String { + try await performBlockingQuery { handle in + let result = proposalId.withCString { idPtr in + dash_sdk_decision_proposal_get_vote_poll_id(handle, idPtr) + } + guard let pollId = try DecisionProposalFFI.consumeString(result) else { + throw SDKError.internalError("dash_sdk_decision_proposal_get_vote_poll_id returned no data") + } + return pollId + } + } + + /// The status and tallies of a decision proposal's yes/no poll, or `nil` + /// when the poll was never opened + /// (`dash_sdk_decision_proposal_get_vote_poll_state`). + /// + /// - Parameter proposalId: The proposal's id, base58. + public nonisolated func decisionProposalVotePollState( + proposalId: String + ) async throws -> YesNoVotePollState? { + try await performBlockingQuery { handle in + let result = proposalId.withCString { idPtr in + dash_sdk_decision_proposal_get_vote_poll_state(handle, idPtr) + } + return try DecisionProposalFFI.consumeString(result).map { + try DecisionProposalJSON.decode(YesNoVotePollState.self, from: $0) + } + } + } + + /// A masternode's answer on a decision proposal, or `nil` when it has not + /// voted on it or the poll has finished (which removes its votes) + /// (`dash_sdk_decision_proposal_get_masternode_vote`). + /// + /// - Parameters: + /// - proposalId: The proposal's id, base58. + /// - proTxHash: The masternode's 32-byte pro_tx_hash in **wire order**, + /// the same bytes + /// ``castDecisionProposalVote(proposalId:choice:proTxHash:votingPrivateKey:)`` + /// takes (see + /// ``SDK/castContestedResourceVote(dataContractId:documentTypeName:indexName:indexValues:choice:proTxHash:votingPrivateKey:)`` + /// for why the orientation matters). + public nonisolated func decisionProposalMasternodeVote( + proposalId: String, + proTxHash: Data + ) async throws -> MasternodeYesNoVote? { + // The FFI reads exactly 32 bytes through the pointer. + guard proTxHash.count == 32 else { + throw SDKError.invalidParameter("proTxHash must be exactly 32 bytes") + } + return try await performBlockingQuery { handle in + let result = proTxHash.withUnsafeBytes { proTxPtr in + proposalId.withCString { idPtr in + dash_sdk_decision_proposal_get_masternode_vote( + handle, + proTxPtr.bindMemory(to: UInt8.self).baseAddress, + idPtr + ) + } + } + return try DecisionProposalFFI.consumeString(result).map { + try DecisionProposalJSON.decode(MasternodeYesNoVote.self, from: $0) + } + } + } + + // MARK: - Vote + + /// Answer a decision proposal for a masternode and wait for Platform's + /// response (`dash_sdk_decision_proposal_cast_vote`). + /// + /// The vote is built, signed with the masternode voting key derived from + /// `votingPrivateKey` and broadcast on the Rust side, exactly as + /// ``castContestedResourceVote(dataContractId:documentTypeName:indexName:indexValues:choice:proTxHash:votingPrivateKey:)`` + /// does for a contest. A wallet that is not a masternode has no voter + /// identity, so Platform refuses its vote. + /// + /// - Parameters: + /// - proposalId: The proposal's id, base58. + /// - choice: Yes, no or abstain. + /// - proTxHash: The masternode's 32-byte pro_tx_hash in **wire order**, + /// not the byte order of the hex Core displays (see + /// ``castContestedResourceVote(dataContractId:documentTypeName:indexName:indexValues:choice:proTxHash:votingPrivateKey:)``). + /// - votingPrivateKey: The masternode's 32-byte voting private key. It + /// is not retained. + public nonisolated func castDecisionProposalVote( + proposalId: String, + choice: YesNoAbstainVoteChoice, + proTxHash: Data, + votingPrivateKey: Data + ) async throws { + // The FFI reads exactly 32 bytes through each pointer. + guard proTxHash.count == 32 else { + throw SDKError.invalidParameter("proTxHash must be exactly 32 bytes") + } + guard votingPrivateKey.count == 32 else { + throw SDKError.invalidParameter("votingPrivateKey must be exactly 32 bytes") + } + let voteChoice = choice.ffiTag + let networkValue = network.ffiValue + + try await performBlockingQuery { handle in + let result = proTxHash.withUnsafeBytes { proTxPtr in + votingPrivateKey.withUnsafeBytes { keyPtr in + proposalId.withCString { idPtr in + dash_sdk_decision_proposal_cast_vote( + handle, + idPtr, + voteChoice, + proTxPtr.bindMemory(to: UInt8.self).baseAddress, + keyPtr.bindMemory(to: UInt8.self).baseAddress, + networkValue + ) + } + } + } + // No data on success. + _ = try DecisionProposalFFI.consumeString(result) + } + } + + // MARK: - Create and edit (documents for the platform wallet) + + /// Build a decision proposal of `ownerIdentityId` asking what `params` + /// says (`dash_sdk_decision_proposal_build`). Nothing is broadcast. + /// + /// Create it with `ManagedPlatformWallet.createDecisionProposal`, which + /// passes its ``DecisionProposalDocument/propertiesJSON()`` to the wallet's + /// document create. The create states the decision proposal fund on top + /// of the fees. Platform judges the text, the rule and the end date then; + /// nothing is checked before. A network below protocol version 15 has no + /// decision proposals contract, which is an error. + /// + /// - Parameter ownerIdentityId: The proposer's identity id, base58. + public nonisolated func buildDecisionProposal( + ownerIdentityId: String, + params: DecisionProposalParams + ) async throws -> DecisionProposalDocument { + try await performBlockingQuery { handle in + let result = ownerIdentityId.withCString { ownerPtr in + params.title.withCString { titlePtr in + params.description.withCString { descriptionPtr in + withDecisionProposalCString(params.url) { urlPtr in + let ffiParams = DashSDKDecisionProposalParams( + title: titlePtr, + description: descriptionPtr, + url: urlPtr, + pass_percent: params.passPercent, + minimum_turnout_percent: params.minimumTurnoutPercent, + end_date_ms: params.endDateMs + ) + return withUnsafePointer(to: ffiParams) { paramsPtr in + dash_sdk_decision_proposal_build(handle, ownerPtr, paramsPtr) + } + } + } + } + } + return try DecisionProposalFFI.consumeBuildResult(result) + } + } + + /// The stored decision proposal `proposalId` names, as a document to edit + /// with ``DecisionProposalDocument/edited(_:)``, or `nil` when there is + /// none (`dash_sdk_decision_proposal_fetch_document`). + /// + /// - Parameter proposalId: The proposal's id, base58. + public nonisolated func fetchDecisionProposalDocument( + proposalId: String + ) async throws -> DecisionProposalDocument? { + try await performBlockingQuery { handle in + let result = proposalId.withCString { idPtr in + dash_sdk_decision_proposal_fetch_document(handle, idPtr) + } + return try DecisionProposalFFI.consumeDocument(result) + } + } +} + +// MARK: - FFI result handling + +/// Takes ownership of the `DashSDKResult`s the decision proposal functions +/// return: every owned string, document handle and error is freed on every +/// path, so only Swift values (or a ``DecisionProposalDocument`` that owns +/// its handle) leave these functions. +private enum DecisionProposalFFI { + /// Throw the typed FFI error, freeing it. + static func throwIfFailed(_ result: DashSDKResult) throws { + guard let error = result.error else { return } + // Keep the FFI error code, so a refused vote or create surfaces as a + // consensus rejection and a transport failure as `.networkError` or + // `.timeout`. + let sdkError = SDKError.fromDashSDKError(error.pointee) + dash_sdk_error_free(error) + throw sdkError + } + + /// The string the result carries, copied and freed; `nil` for no data. + static func consumeString(_ result: DashSDKResult) throws -> String? { + try throwIfFailed(result) + guard let data = result.data else { return nil } + let pointer = data.assumingMemoryBound(to: CChar.self) + let string = String(cString: pointer) + dash_sdk_string_free(pointer) + return string + } + + /// A listing page: a JSON array, `[]` when empty and never no data. + static func consumeListing(_ result: DashSDKResult, function: String) throws -> [DecisionProposal] { + guard let json = try consumeString(result) else { + throw SDKError.internalError("\(function) returned no data") + } + return try DecisionProposalJSON.decode([DecisionProposal].self, from: json) + } + + /// The document handle the result carries, now owned by the returned + /// object; `nil` for no data. + static func consumeDocument(_ result: DashSDKResult) throws -> DecisionProposalDocument? { + try throwIfFailed(result) + guard let data = result.data else { return nil } + return DecisionProposalDocument(handle: OpaquePointer(data)) + } + + /// The document of a `DashSDKDocumentCreateResult`. The struct is freed; + /// its document handle stays alive and is owned by the returned object. + /// The entropy is not kept: the platform wallet's create draws its own. + static func consumeBuildResult(_ result: DashSDKResult) throws -> DecisionProposalDocument { + try throwIfFailed(result) + guard let data = result.data else { + throw SDKError.internalError("dash_sdk_decision_proposal_build returned no data") + } + let createResult = data.assumingMemoryBound(to: DashSDKDocumentCreateResult.self) + let documentHandle = createResult.pointee.document_handle + dash_sdk_document_create_result_free(createResult) + guard let documentHandle else { + throw SDKError.internalError("dash_sdk_decision_proposal_build returned no document handle") + } + return DecisionProposalDocument(handle: documentHandle) + } +} + +/// Run `body` with `string` as a C string, or with `nil` when it is `nil`. +private func withDecisionProposalCString( + _ string: String?, + _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + guard let string else { return try body(nil) } + return try string.withCString { try body($0) } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift new file mode 100644 index 00000000000..af009332917 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift @@ -0,0 +1,550 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// One decision proposal: its text and rule, its yes/no poll (open with its +/// tallies, or finished with its result), a masternode vote, and the text +/// edit for a proposal a local identity filed. +/// +/// Everything shown is read from Platform through the `SDK` decision +/// proposal wrappers on appear and on pull-to-refresh. Whether a vote or an +/// edit is accepted is Platform's call; this screen only collects input and +/// shows the outcome. +struct DecisionProposalDetailView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Query private var identities: [PersistentIdentity] + + @State private var proposal: DecisionProposal + @State private var pollState: YesNoVotePollState? + /// `true` once the poll state has been read, so "never opened" is not + /// shown while it is still loading. + @State private var pollStateLoaded = false + @State private var pollId: String? + @State private var isRefreshing = false + @State private var errorMessage: String? + + @State private var showVoteSheet = false + @State private var showEditSheet = false + /// Banner after a vote or an edit. + @State private var actionMessage: String? + @State private var actionMessageIsError = false + + init(proposal: DecisionProposal) { + _proposal = State(initialValue: proposal) + } + + /// The proposer, when it is an identity on this device held by a loaded + /// wallet, so it can sign an edit. + private var localOwner: PersistentIdentity? { + identities.first { + $0.network == appState.currentNetwork + && $0.wallet != nil + && $0.identityIdBase58 == proposal.ownerId + } + } + + var body: some View { + List { + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + } + + proposalSection + ruleSection + pollSection + actionsSection + } + .navigationTitle("Proposal") + .navigationBarTitleDisplayMode(.inline) + .refreshable { + await refresh() + } + .task { + await refresh() + } + .sheet(isPresented: $showVoteSheet) { + DecisionProposalVoteSheet( + proposalId: proposal.id, + proposalTitle: proposal.title + ) { message in + actionMessage = message + actionMessageIsError = false + Task { await refresh() } + } + .environmentObject(appState) + } + .sheet(isPresented: $showEditSheet) { + if let owner = localOwner { + EditDecisionProposalView(proposal: proposal, owner: owner) { message in + actionMessage = message + actionMessageIsError = false + Task { await refresh() } + } + .environmentObject(appState) + .environmentObject(walletManager) + } + } + } + + // MARK: - Sections + + private var proposalSection: some View { + Section("Proposal") { + Text(proposal.title) + .font(.headline) + .fixedSize(horizontal: false, vertical: true) + Text(proposal.description) + .font(.body) + .fixedSize(horizontal: false, vertical: true) + if let urlString = proposal.url { + if let url = URL(string: urlString) { + Link(urlString, destination: url) + .font(.caption) + } else { + Text(urlString) + .font(.caption) + } + } + DetailRow(label: "Proposal ID", value: proposal.id) + DetailRow( + label: "Proposer", + value: localOwner.map { "\($0.alias ?? $0.identityIdBase58) (this device)" } + ?? proposal.ownerId + ) + if let revision = proposal.revision { + DetailRow(label: "Revision", value: "\(revision)") + } + if let createdAt = proposal.createdAt { + DetailRow(label: "Created", value: createdAt.formatted(date: .abbreviated, time: .shortened)) + } + if let updatedAt = proposal.updatedAt, updatedAt != proposal.createdAt { + DetailRow(label: "Edited", value: updatedAt.formatted(date: .abbreviated, time: .shortened)) + } + } + } + + private var ruleSection: some View { + Section { + DetailRow(label: "Pass", value: "Yes is more than \(proposal.passPercent)% of yes plus no") + DetailRow( + label: "Minimum turnout", + value: "Yes plus no reaches \(proposal.minimumTurnoutPercent)% of the total voting power" + ) + HStack { + Label("Voting ends", systemImage: "clock") + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(proposal.endDate, style: .relative) + .font(.caption) + .foregroundColor(.orange) + Text(proposal.endDate, format: .dateTime.month().day().hour().minute()) + .font(.caption2) + .foregroundColor(.secondary) + } + } + if let poll = pollState?.votePoll { + DetailRow( + label: "Poll supermajority", + value: "\(poll.supermajorityNumerator)/\(poll.supermajorityDenominator)" + ) + DetailRow(label: "Poll minimum voting power", value: Self.describe(poll.minimumVotingPower)) + } + } header: { + Text("Rule") + } footer: { + Text("The rule and the end date never change after the proposal is created.") + } + } + + @ViewBuilder + private var pollSection: some View { + Section { + if !pollStateLoaded { + HStack { + ProgressView() + Text("Loading poll…") + .font(.caption) + .foregroundColor(.secondary) + } + } else if let pollState { + switch pollState.status { + case .started(let startBlock): + statusRow(text: "Voting open", color: .orange) + blockRow(label: "Opened", block: startBlock) + tallyRows( + yes: pollState.yesVotingPower, + no: pollState.noVotingPower, + abstain: pollState.abstainVotingPower + ) + case .finished(let result): + statusRow( + text: result.passed ? "Passed" : "Did not pass", + color: result.passed ? .green : .red + ) + tallyRows( + yes: result.yesVotingPower, + no: result.noVotingPower, + abstain: result.abstainVotingPower + ) + HStack { + Label("Required yes plus no", systemImage: "person.3") + Spacer() + Text("\(result.requiredVotingPower)") + .font(.headline) + } + blockRow(label: "Opened", block: result.startBlock) + blockRow(label: "Closed", block: result.finalizationBlock) + } + } else { + Text("Platform has no poll for this proposal.") + .font(.caption) + .foregroundColor(.secondary) + } + if let pollId { + DetailRow(label: "Poll ID", value: pollId) + } + } header: { + Text("Poll") + } footer: { + Text("Voting power counts a masternode 1 and an evonode 4. Abstaining is recorded but plays no part in the result.") + } + } + + private var actionsSection: some View { + Section("Actions") { + if let message = actionMessage { + Label( + message, + systemImage: actionMessageIsError ? "exclamationmark.triangle" : "checkmark.circle.fill" + ) + .font(.caption) + .foregroundColor(actionMessageIsError ? .orange : .green) + } + + Button { + actionMessage = nil + showVoteSheet = true + } label: { + Label("Vote as Masternode…", systemImage: "hand.raised.fill") + } + // A finished poll keeps no votes; Platform refuses one. + .disabled(appState.sdk == nil || pollState?.result != nil) + .accessibilityIdentifier("decisionProposal.vote") + + if localOwner != nil { + Button { + actionMessage = nil + showEditSheet = true + } label: { + Label("Edit Text…", systemImage: "pencil") + } + .disabled(appState.sdk == nil) + .accessibilityIdentifier("decisionProposal.edit") + + Text("Platform accepts an edit of the title, description and url for a day after the proposal is created (or a fifth of its voting time, if that is shorter).") + .font(.caption2) + .foregroundColor(.secondary) + } + + Text("Only masternodes vote, with the voting key of a pro_tx_hash. A regular wallet has no voting key, so Platform refuses its vote.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + // MARK: - Rows + + private func statusRow(text: String, color: Color) -> some View { + HStack { + Label("Status", systemImage: "flag.fill") + Spacer() + Text(text) + .foregroundColor(color) + } + } + + private func blockRow(label: String, block: PlatformBlockInfo) -> some View { + DetailRow( + label: label, + value: "Block \(block.height), epoch \(block.epoch), \(block.time.formatted(date: .abbreviated, time: .shortened))" + ) + } + + @ViewBuilder + private func tallyRows(yes: UInt64, no: UInt64, abstain: UInt64) -> some View { + tallyRow("Yes", systemImage: "hand.thumbsup.fill", value: yes, color: .green) + tallyRow("No", systemImage: "hand.thumbsdown.fill", value: no, color: .red) + tallyRow("Abstain", systemImage: "minus.circle", value: abstain, color: .gray) + } + + private func tallyRow(_ title: String, systemImage: String, value: UInt64, color: Color) -> some View { + HStack { + Label(title, systemImage: systemImage) + .foregroundColor(color) + Spacer() + Text("\(value)") + .font(.headline) + } + } + + private static func describe(_ minimum: YesNoMinimumVotingPower) -> String { + switch minimum { + case .absolute(let votingPower): + return "\(votingPower) voting power" + case .fractionOfTotal(let numerator, let denominator, let rounding): + return "\(numerator)/\(denominator) of the total, rounded \(describe(rounding))" + } + } + + private static func describe(_ rounding: VotingPowerRounding) -> String { + switch rounding { + case .down: return "down" + case .up: return "up" + case .downPlusOne: return "down plus one" + case .upMinusOne: return "up minus one" + } + } + + // MARK: - Refresh + + /// Read the proposal, its poll state and its poll id. Each read stands on + /// its own, so one failing still shows what the others returned. + private func refresh() async { + guard !isRefreshing else { return } + guard let sdk = appState.sdk else { + errorMessage = "SDK not initialized" + return + } + isRefreshing = true + defer { isRefreshing = false } + + let id = proposal.id + async let fetched = capture { try await sdk.decisionProposal(id: id) } + async let state = capture { try await sdk.decisionProposalVotePollState(proposalId: id) } + async let pollIdValue = capture { try await sdk.decisionProposalVotePollId(proposalId: id) } + + var failures: [String] = [] + switch await fetched { + case .success(let value?): + proposal = value + case .success(nil): + failures.append("Platform no longer has this proposal.") + case .failure(let error): + failures.append("Proposal: \(error.localizedDescription)") + } + switch await state { + case .success(let value): + pollState = value + pollStateLoaded = true + case .failure(let error): + failures.append("Poll state: \(error.localizedDescription)") + } + switch await pollIdValue { + case .success(let value): + pollId = value + case .failure(let error): + failures.append("Poll id: \(error.localizedDescription)") + } + errorMessage = failures.isEmpty ? nil : failures.joined(separator: "\n") + } +} + +/// `operation`'s outcome as a `Result`, so reads started together can fail +/// on their own. +private func capture( + _ operation: @Sendable () async throws -> T +) async -> Result { + do { + return .success(try await operation()) + } catch { + return .failure(error) + } +} + +// MARK: - Masternode vote + +/// Picks the answer and takes the masternode credentials, the same way the +/// DPNS contest vote sheet does: the pro_tx_hash and the voting private key +/// as hex. Also reads the masternode's current answer. +private struct DecisionProposalVoteSheet: View { + let proposalId: String + let proposalTitle: String + /// Called with a summary once Platform accepted the vote. + let onVoted: (String) -> Void + + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + @State private var choice: YesNoAbstainVoteChoice = .yes + @State private var proTxHashHex = "" + @State private var votingKeyHex = "" + @State private var isCasting = false + @State private var isLookingUp = false + @State private var message: String? + @State private var messageIsError = false + + var body: some View { + NavigationStack { + Form { + Section("Voting on") { + Text(proposalTitle) + .font(.headline) + } + + Section("Your Answer") { + Picker("Answer", selection: $choice) { + ForEach(YesNoAbstainVoteChoice.allCases) { choice in + Text(choice.rawValue.capitalized).tag(choice) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("decisionProposalVote.choice") + } + + Section { + TextField("pro_tx_hash (64 hex chars)", text: $proTxHashHex) + .font(.system(.caption, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("decisionProposalVote.proTxHash") + SecureField("voting private key (64 hex chars)", text: $votingKeyHex) + .font(.system(.caption, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("decisionProposalVote.votingKey") + } header: { + Text("Masternode Credentials") + } footer: { + Text("The pro_tx_hash goes in wire order, the reverse of the hex Core displays. The voting public key and signer are derived from the private key on the Rust side; the key bytes are not stored.") + } + + Section { + Button { + Task { await castVote() } + } label: { + if isCasting { + HStack { + ProgressView() + Text("Broadcasting…") + } + } else { + Text("Submit Vote") + } + } + .disabled(isCasting || isLookingUp || proTxHashHex.isEmpty || votingKeyHex.isEmpty) + .accessibilityIdentifier("decisionProposalVote.submit") + + Button { + Task { await lookUpAnswer() } + } label: { + if isLookingUp { + HStack { + ProgressView() + Text("Reading…") + } + } else { + Text("Read This Masternode's Answer") + } + } + .disabled(isCasting || isLookingUp || proTxHashHex.isEmpty) + .accessibilityIdentifier("decisionProposalVote.lookUp") + + if let message { + Text(message) + .font(.caption) + .foregroundColor(messageIsError ? .orange : .secondary) + } + } + } + .navigationTitle("Vote") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + .disabled(isCasting) + } + } + .interactiveDismissDisabled(isCasting) + } + } + + /// Decode a 32-byte value typed as 64 hex characters. `Data(hexString:)` + /// drops a trailing odd nibble, so the length is checked first, as on the + /// contest vote sheet. + private static func decode32(_ hex: String) -> Data? { + let trimmed = hex.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count == 64, let data = Data(hexString: trimmed), data.count == 32 else { + return nil + } + return data + } + + private func castVote() async { + guard let sdk = appState.sdk else { + show("SDK not initialized", isError: true) + return + } + guard let proTxHash = Self.decode32(proTxHashHex) else { + show("pro_tx_hash must be 32 bytes (64 hex characters).", isError: true) + return + } + guard let votingKey = Self.decode32(votingKeyHex) else { + show("Voting private key must be 32 bytes (64 hex characters).", isError: true) + return + } + + isCasting = true + defer { isCasting = false } + do { + try await sdk.castDecisionProposalVote( + proposalId: proposalId, + choice: choice, + proTxHash: proTxHash, + votingPrivateKey: votingKey + ) + onVoted("Vote \"\(choice.rawValue)\" accepted by Platform.") + dismiss() + } catch { + show("Vote rejected: \(error.localizedDescription)", isError: true) + } + } + + private func lookUpAnswer() async { + guard let sdk = appState.sdk else { + show("SDK not initialized", isError: true) + return + } + guard let proTxHash = Self.decode32(proTxHashHex) else { + show("pro_tx_hash must be 32 bytes (64 hex characters).", isError: true) + return + } + + isLookingUp = true + defer { isLookingUp = false } + do { + if let vote = try await sdk.decisionProposalMasternodeVote( + proposalId: proposalId, + proTxHash: proTxHash + ) { + show( + "Current answer: \(vote.choice.rawValue), voted \(vote.timesVoted) time\(vote.timesVoted == 1 ? "" : "s").", + isError: false + ) + } else { + show("This masternode has no answer on the poll (none yet, or the poll has finished).", isError: false) + } + } catch { + show("Reading the answer failed: \(error.localizedDescription)", isError: true) + } + } + + private func show(_ text: String, isError: Bool) { + message = text + messageIsError = isError + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift new file mode 100644 index 00000000000..1de6f7aede8 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift @@ -0,0 +1,415 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +// MARK: - Create + +/// Files a new decision proposal for an identity held by a loaded wallet. +/// +/// rs-sdk builds the proposal (`SDK.buildDecisionProposal`) and the platform +/// wallet creates it (`ManagedPlatformWallet.createDecisionProposal`), the +/// same document-create path `CreateDocumentView` takes, so the signing key +/// is chosen and used on the Rust side through the wallet's +/// `KeychainSigner`. Platform judges the text, the rule and the end date; +/// this form only collects them. +struct CreateDecisionProposalView: View { + /// Called with the new proposal's id (base58) once Platform confirmed it. + let onCreated: (String) -> Void + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @Query private var identities: [PersistentIdentity] + + /// Base58 id of the proposer. + @State private var selectedOwnerId = "" + @State private var title = "" + @State private var description = "" + @State private var url = "" + @State private var passPercent = 60 + @State private var minimumTurnoutPercent = 10 + @State private var endDate = Date().addingTimeInterval(14 * 24 * 60 * 60) + + @State private var isSubmitting = false + @State private var submitError: String? + @State private var createdProposalId: String? + + /// Identities on the active network held by a loaded wallet, so a + /// `KeychainSigner` can sign for them. + private var ownerIdentities: [PersistentIdentity] { + identities.filter { $0.network == appState.currentNetwork && $0.wallet != nil } + } + + private var selectedOwner: PersistentIdentity? { + ownerIdentities.first { $0.identityIdBase58 == selectedOwnerId } + } + + private var managedWallet: ManagedPlatformWallet? { + guard let walletId = selectedOwner?.wallet?.walletId else { return nil } + return walletManager.wallet(for: walletId) + } + + var body: some View { + NavigationStack { + Form { + if let createdProposalId { + successSection(proposalId: createdProposalId) + } else { + ownerSection + questionSection + ruleSection + submitSection + } + } + .navigationTitle("New Proposal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(createdProposalId == nil ? "Cancel" : "Done") { dismiss() } + .disabled(isSubmitting) + } + } + // The broadcast is not idempotent; keep the sheet up until it ends. + .interactiveDismissDisabled(isSubmitting) + .alert( + "Create failed", + isPresented: Binding( + get: { submitError != nil }, + set: { if !$0 { submitError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(submitError ?? "") + } + .onAppear { + if selectedOwnerId.isEmpty, let first = ownerIdentities.first { + selectedOwnerId = first.identityIdBase58 + } + } + } + } + + // MARK: - Sections + + private var ownerSection: some View { + Section { + Picker("Proposer", selection: $selectedOwnerId) { + Text("Select identity").tag("") + ForEach(ownerIdentities) { identity in + Text(identity.alias ?? identity.identityIdBase58) + .tag(identity.identityIdBase58) + } + } + .accessibleFormPicker("createDecisionProposal.ownerPicker") + .disabled(isSubmitting) + } header: { + Text("Proposer") + } footer: { + Text("The create pays the decision proposal fund (0.5 Dash) from this identity's credits, on top of the fees.") + } + } + + private var questionSection: some View { + Section { + TextField("Title: the question, in one line", text: $title) + .accessibilityIdentifier("createDecisionProposal.title") + TextField("Description: what a yes and a no mean", text: $description, axis: .vertical) + .lineLimit(3...10) + .accessibilityIdentifier("createDecisionProposal.description") + TextField("URL where it is discussed (optional)", text: $url) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("createDecisionProposal.url") + } header: { + Text("Question") + } footer: { + Text("The title, description and url can be edited for a day after the proposal is created.") + } + .disabled(isSubmitting) + } + + private var ruleSection: some View { + Section { + Stepper(value: $passPercent, in: 0...100) { + LabeledContent("Passes when yes is more than", value: "\(passPercent)%") + } + .accessibilityIdentifier("createDecisionProposal.passPercent") + Stepper(value: $minimumTurnoutPercent, in: 0...100) { + LabeledContent("Minimum turnout", value: "\(minimumTurnoutPercent)%") + } + .accessibilityIdentifier("createDecisionProposal.turnoutPercent") + DatePicker("Voting ends", selection: $endDate, in: Date()...) + .accessibilityIdentifier("createDecisionProposal.endDate") + } header: { + Text("Rule") + } footer: { + Text("Yes must be more than the pass percent of yes plus no, and yes plus no must reach the turnout percent of the masternodes' total voting power. Platform accepts a pass percent of 50 to 80, a turnout of 0 to 50, and an end date one to four weeks after the block that creates the proposal. The rule never changes.") + } + .disabled(isSubmitting) + } + + private var submitSection: some View { + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView() + .controlSize(.small) + Text("Broadcasting…") + } else { + Text("Create / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("createDecisionProposal.submitButton") + .disabled(isSubmitting || appState.sdk == nil || managedWallet == nil) + } + } + + private func successSection(proposalId: String) -> some View { + Section { + VStack(alignment: .leading, spacing: 8) { + Label("Proposal created", systemImage: "checkmark.seal.fill") + .foregroundColor(.green) + .font(.headline) + HStack(alignment: .top) { + Text("ID:") + .foregroundColor(.secondary) + Text(proposalId) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.middle) + } + Button { + dismiss() + } label: { + Text("Done") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("createDecisionProposal.doneButton") + .padding(.top, 4) + } + } + } + + // MARK: - Submit + + private func submit() { + guard let sdk = appState.sdk else { + submitError = "SDK not initialized" + return + } + guard let owner = selectedOwner, let wallet = managedWallet else { + submitError = DocumentActionError.noWallet.localizedDescription + return + } + + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let params = DecisionProposalParams( + title: title, + description: description, + url: trimmedURL.isEmpty ? nil : trimmedURL, + passPercent: UInt8(clamping: passPercent), + minimumTurnoutPercent: UInt8(clamping: minimumTurnoutPercent), + endDate: endDate + ) + let ownerIdBase58 = owner.identityIdBase58 + let ownerId = owner.identityId + // Fresh `KeychainSigner` per submit, as `CreateDocumentView` does. + let signer = KeychainSigner(modelContainer: modelContext.container) + + isSubmitting = true + Task { + do { + let proposal = try await sdk.buildDecisionProposal( + ownerIdentityId: ownerIdBase58, + params: params + ) + let (proposalId, _) = try await wallet.createDecisionProposal( + proposal, + ownerIdentityId: ownerId, + signer: signer + ) + let proposalIdBase58 = proposalId.toBase58String() + createdProposalId = proposalIdBase58 + onCreated(proposalIdBase58) + } catch { + submitError = error.localizedDescription + } + isSubmitting = false + } + } +} + +// MARK: - Edit + +/// Gives a proposal a local identity filed new text. +/// +/// rs-sdk reads the stored proposal and applies the edit +/// (`SDK.fetchDecisionProposalDocument`, `DecisionProposalDocument.edited`), +/// refusing one that changes nothing; the platform wallet replaces it +/// (`ManagedPlatformWallet.replaceDecisionProposal`), signing with the key +/// `DocumentActionRunner.resolveSigning` picks, as `ReplaceDocumentView` +/// does. Platform refuses the replace outside the edit window. +struct EditDecisionProposalView: View { + let proposal: DecisionProposal + let owner: PersistentIdentity + /// Called with a summary once Platform confirmed the edit. + let onEdited: (String) -> Void + + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var title: String + @State private var description: String + @State private var url: String + @State private var isSubmitting = false + @State private var submitError: String? + + init(proposal: DecisionProposal, owner: PersistentIdentity, onEdited: @escaping (String) -> Void) { + self.proposal = proposal + self.owner = owner + self.onEdited = onEdited + _title = State(initialValue: proposal.title) + _description = State(initialValue: proposal.description) + _url = State(initialValue: proposal.url ?? "") + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Title", text: $title) + .accessibilityIdentifier("editDecisionProposal.title") + TextField("Description", text: $description, axis: .vertical) + .lineLimit(3...10) + .accessibilityIdentifier("editDecisionProposal.description") + TextField("URL (empty for none)", text: $url) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .accessibilityIdentifier("editDecisionProposal.url") + } header: { + Text("Text") + } footer: { + Text("Only the title, description and url can change, and only for a day after the proposal was created (or a fifth of its voting time, if that is shorter).") + } + .disabled(isSubmitting) + + Section { + Button { + submit() + } label: { + HStack { + if isSubmitting { + ProgressView() + .controlSize(.small) + Text("Broadcasting…") + } else { + Text("Replace / Broadcast") + } + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("editDecisionProposal.submitButton") + .disabled(isSubmitting || appState.sdk == nil) + } + } + .navigationTitle("Edit Proposal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .disabled(isSubmitting) + } + } + .interactiveDismissDisabled(isSubmitting) + .alert( + "Edit failed", + isPresented: Binding( + get: { submitError != nil }, + set: { if !$0 { submitError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(submitError ?? "") + } + } + } + + private func submit() { + guard let sdk = appState.sdk else { + submitError = "SDK not initialized" + return + } + guard let proposalId = Data.identifier(fromBase58: proposal.id), proposalId.count == 32 else { + submitError = "The proposal id is not a 32-byte base58 identifier." + return + } + let wallet: ManagedPlatformWallet + let signingKeyId: UInt32 + do { + // The proposal type states no signature security level, so the + // document type is left out and the HIGH default applies. + (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( + for: owner, + documentType: nil, + walletManager: walletManager + ) + } catch { + submitError = error.localizedDescription + return + } + + // An emptied url field removes the url; an untouched empty one keeps + // having none. + let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) + let urlChange: DecisionProposalEdit.URLChange + if trimmedURL.isEmpty { + urlChange = proposal.url == nil ? .keep : .remove + } else { + urlChange = .set(trimmedURL) + } + let edit = DecisionProposalEdit(title: title, description: description, url: urlChange) + let ownerId = owner.identityId + let proposalIdBase58 = proposal.id + let signer = KeychainSigner(modelContainer: modelContext.container) + + isSubmitting = true + Task { + do { + guard let stored = try await sdk.fetchDecisionProposalDocument( + proposalId: proposalIdBase58 + ) else { + throw SDKError.notFound("Platform has no proposal \(proposalIdBase58)") + } + let edited = try stored.edited(edit) + _ = try await wallet.replaceDecisionProposal( + edited, + ownerIdentityId: ownerId, + proposalId: proposalId, + signingKeyId: signingKeyId, + signer: signer + ) + onEdited("Edit accepted by Platform.") + isSubmitting = false + dismiss() + } catch { + submitError = error.localizedDescription + isSubmitting = false + } + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalsView.swift new file mode 100644 index 00000000000..58ed89fff70 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalsView.swift @@ -0,0 +1,260 @@ +import SwiftUI +import SwiftData +import SwiftDashSDK + +/// Decision proposals (protocol version 15): yes/no questions any identity +/// puts to the masternodes. +/// +/// Lists them three ways, each one Platform listing read a page at a time: +/// the open ones closing soonest first, the ones a local identity filed, and +/// the newest. Drills into ``DecisionProposalDetailView`` for the poll state, +/// the masternode vote and the edit, and creates new ones with +/// ``CreateDecisionProposalView``. Every read goes through the `SDK` +/// decision proposal wrappers; nothing here decides what a proposal is. +struct DecisionProposalsView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var walletManager: PlatformWalletManager + @Query private var identities: [PersistentIdentity] + + private enum Listing: String, CaseIterable, Identifiable { + case open = "Open" + case mine = "Mine" + case newest = "Newest" + + var id: String { rawValue } + + var footer: String { + switch self { + case .open: + return "Proposals whose voting closes from now on, closing soonest first." + case .mine: + return "Proposals the selected identity filed, in the order it filed them." + case .newest: + return "Every proposal, newest first." + } + } + } + + /// Proposals per page. + private static let pageSize: UInt32 = 20 + + @State private var listing: Listing = .open + /// Base58 id of the identity whose proposals "Mine" lists. + @State private var selectedOwnerId = "" + @State private var proposals: [DecisionProposal] = [] + /// `false` once a page came back shorter than the page size (the last). + @State private var hasMore = false + @State private var isLoading = false + @State private var errorMessage: String? + /// Bumped on every fresh load so a page that arrives late for a previous + /// listing is dropped instead of mixed in. + @State private var loadGeneration = 0 + @State private var showCreate = false + + /// Identities on the active network, for the "Mine" listing. + private var localIdentities: [PersistentIdentity] { + identities.filter { $0.network == appState.currentNetwork } + } + + var body: some View { + List { + Section { + Picker("Listing", selection: $listing) { + ForEach(Listing.allCases) { value in + Text(value.rawValue).tag(value) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("decisionProposals.listing") + + if listing == .mine { + Picker("Identity", selection: $selectedOwnerId) { + Text("Select identity").tag("") + ForEach(localIdentities) { identity in + Text(identity.alias ?? identity.identityIdBase58) + .tag(identity.identityIdBase58) + } + } + .accessibilityIdentifier("decisionProposals.ownerPicker") + } + } footer: { + Text(listing.footer) + } + + if let errorMessage { + Section { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + } + + Section("Proposals") { + if proposals.isEmpty { + if isLoading { + HStack { + ProgressView() + Text("Loading proposals…") + .font(.caption) + .foregroundColor(.secondary) + } + } else if listing == .mine && selectedOwnerId.isEmpty { + Text("Select an identity to list its proposals.") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("No proposals") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + ForEach(proposals) { proposal in + NavigationLink( + destination: DecisionProposalDetailView(proposal: proposal) + ) { + DecisionProposalRow(proposal: proposal) + } + .accessibilityIdentifier("decisionProposals.row.\(proposal.id)") + } + + if hasMore { + Button { + Task { await loadNextPage() } + } label: { + HStack { + if isLoading { + ProgressView().controlSize(.small) + } + Text("Load More") + } + .frame(maxWidth: .infinity) + } + .disabled(isLoading) + .accessibilityIdentifier("decisionProposals.loadMore") + } + } + } + } + .navigationTitle("Decision Proposals") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showCreate = true + } label: { + Image(systemName: "plus") + } + .disabled(appState.sdk == nil) + .accessibilityLabel("New decision proposal") + .accessibilityIdentifier("decisionProposals.create") + } + } + .refreshable { + await reload() + } + .task(id: "\(listing.rawValue)|\(selectedOwnerId)") { + await reload() + } + .onChange(of: listing) { _, newListing in + // Start "Mine" on the first local identity rather than empty. + if newListing == .mine, selectedOwnerId.isEmpty, + let first = localIdentities.first { + selectedOwnerId = first.identityIdBase58 + } + } + .sheet(isPresented: $showCreate) { + CreateDecisionProposalView { _ in + Task { await reload() } + } + .environmentObject(appState) + .environmentObject(walletManager) + } + } + + // MARK: - Loading + + /// Load the first page of the current listing. + private func reload() async { + loadGeneration += 1 + proposals = [] + hasMore = false + errorMessage = nil + if listing == .mine && selectedOwnerId.isEmpty { return } + await load(startAfter: nil, generation: loadGeneration) + } + + /// Load the page after the last proposal shown. + private func loadNextPage() async { + guard let last = proposals.last else { return } + await load(startAfter: last.id, generation: loadGeneration) + } + + private func load(startAfter: String?, generation: Int) async { + guard let sdk = appState.sdk else { + errorMessage = "SDK not initialized" + return + } + isLoading = true + defer { isLoading = false } + + do { + let page: [DecisionProposal] + switch listing { + case .open: + page = try await sdk.decisionProposalsByEndDate( + from: Date(), + limit: Self.pageSize, + startAfter: startAfter + ) + case .mine: + page = try await sdk.decisionProposalsByOwner( + ownerId: selectedOwnerId, + limit: Self.pageSize, + startAfter: startAfter + ) + case .newest: + page = try await sdk.decisionProposalsByCreatedAt( + limit: Self.pageSize, + startAfter: startAfter + ) + } + guard generation == loadGeneration else { return } + proposals.append(contentsOf: page) + // A page shorter than the page size is the last one. + hasMore = page.count == Int(Self.pageSize) + } catch { + guard generation == loadGeneration else { return } + errorMessage = "Loading failed: \(error.localizedDescription)" + } + } +} + +/// One proposal in a listing. +private struct DecisionProposalRow: View { + let proposal: DecisionProposal + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(proposal.title) + .font(.headline) + .lineLimit(2) + HStack(spacing: 12) { + Label { + Text(proposal.endDate, style: proposal.endDate > Date() ? .relative : .date) + } icon: { + Image(systemName: "clock") + } + Label("\(proposal.passPercent)%", systemImage: "checkmark.circle") + Label("\(proposal.minimumTurnoutPercent)%", systemImage: "person.3") + } + .font(.caption) + .foregroundColor(.secondary) + Text(proposal.id) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.vertical, 2) + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformQueriesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformQueriesView.swift index e7494785aef..015e4bad5de 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformQueriesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/PlatformQueriesView.swift @@ -116,6 +116,19 @@ struct QueryCategoryDetailView: View { .padding(.vertical, 4) } .accessibilityIdentifier("systemQueries.getPathElementsLink") + } else if query.name == "decisionProposals" { + NavigationLink(destination: DecisionProposalsView()) { + VStack(alignment: .leading, spacing: 4) { + Text(query.label) + .font(.headline) + Text(query.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + .padding(.vertical, 4) + } + .accessibilityIdentifier("votingQueries.decisionProposalsLink") } else if query.name == "testDPNSQueries" { NavigationLink(destination: DPNSTestView()) { VStack(alignment: .leading, spacing: 4) { @@ -194,6 +207,7 @@ struct QueryCategoryDetailView: View { case .voting: return [ + QueryDefinition(name: "decisionProposals", label: "Decision Proposals", description: "Browse, file, edit and vote on yes/no questions put to the masternodes (protocol version 15)"), QueryDefinition(name: "getContestedResources", label: "Get Contested Resources", description: "Get list of contested resources"), QueryDefinition(name: "getContestedResourceVoteState", label: "Get Contested Resource Vote State", description: "Get the current vote state for a contested resource"), QueryDefinition(name: "getContestedResourceVotersForIdentity", label: "Get Contested Resource Voters for Identity", description: "Get voters who voted for a specific identity in a contested resource"), diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift index a368658e44b..e0a6228a6b9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/QueryDetailView.swift @@ -505,13 +505,23 @@ struct QueryDetailView: View { orderAscending: true ) - // Filter to only DPNS-related polls - let dpnsContractId = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec" - let dpnsPolls = polls.filter { poll in - if let contractId = poll["contractId"] as? String { - return contractId == dpnsContractId + // The listing is grouped by end date: + // `[{"timestamp": , "vote_polls": [, ...]}]`, + // each poll its canonical JSON tagged with `$type`. Flatten the + // groups, keep the contested polls of the DPNS contract (yes/no + // polls carry no `contractId`), and carry each group's end date + // onto its polls so it is not lost with the grouping. + let dpnsPolls = polls.flatMap { group -> [[String: Any]] in + let votePolls = group["vote_polls"] as? [[String: Any]] ?? [] + return votePolls.compactMap { poll in + guard poll["$type"] as? String == "contestedDocumentResourceVotePoll", + poll["contractId"] as? String == DPNSVotePoll.contractId else { + return nil + } + var dpnsPoll = poll + dpnsPoll["timestamp"] = group["timestamp"] + return dpnsPoll } - return false } return dpnsPolls diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalDecodingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalDecodingTests.swift new file mode 100644 index 00000000000..610543f9a47 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalDecodingTests.swift @@ -0,0 +1,280 @@ +import Foundation +import XCTest +@testable import SwiftDashSDK + +/// Hermetic tests for the decision proposal JSON the `rs-sdk-ffi` +/// `dash_sdk_decision_proposal_*` reads return, decoded by +/// `DecisionProposalJSON` exactly as the `SDK` wrappers decode it, and for the +/// `vote_choice` byte `dash_sdk_decision_proposal_cast_vote` takes. +/// +/// The payloads mirror the shapes the FFI's +/// `packages/rs-sdk-ffi/src/decision_proposal/json.rs` writes (and its tests +/// pin). No SDK handle, no network. +final class DecisionProposalDecodingTests: XCTestCase { + private let createdAt: UInt64 = 1_700_000_000_000 + private let endDate: UInt64 = 1_700_000_000_000 + 14 * 86_400_000 + + private func decode(_ type: T.Type, _ json: String) throws -> T { + try DecisionProposalJSON.decode(type, from: json) + } + + private func proposalJSON(url: String) -> String { + """ + {"id":"4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF",\ + "ownerId":"CQgw5i7y2Y4Ns6rTgV6cq7MhQ7b4oJxEf1vsaFgM2KXJ",\ + "revision":1,"createdAt":\(createdAt),"updatedAt":\(createdAt),\ + "title":"Raise the block size?","description":"Yes doubles it; no keeps it.",\ + \(url)"passPercent":60,"minimumTurnoutPercent":10,"endDate":\(endDate)} + """ + } + + private let fractionPollJSON = """ + {"$type":"yesNoVotePoll",\ + "purpose":{"$type":"decisionProposal","documentId":"4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF"},\ + "supermajorityNumerator":60,"supermajorityDenominator":100,\ + "minimumVotingPower":{"$type":"fractionOfTotal","numerator":10,"denominator":100,"rounding":"up"}} + """ + + // MARK: - Proposal + + func testShouldDecodeAProposalWithEveryField() throws { + let proposal = try decode( + DecisionProposal.self, + proposalJSON(url: #""url":"https://forum.dash.org/t/block-size","#) + ) + + XCTAssertEqual(proposal.id, "4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF") + XCTAssertEqual(proposal.ownerId, "CQgw5i7y2Y4Ns6rTgV6cq7MhQ7b4oJxEf1vsaFgM2KXJ") + XCTAssertEqual(proposal.revision, 1) + XCTAssertEqual(proposal.createdAtMs, createdAt) + XCTAssertEqual(proposal.updatedAtMs, createdAt) + XCTAssertEqual(proposal.title, "Raise the block size?") + XCTAssertEqual(proposal.description, "Yes doubles it; no keeps it.") + XCTAssertEqual(proposal.url, "https://forum.dash.org/t/block-size") + XCTAssertEqual(proposal.passPercent, 60) + XCTAssertEqual(proposal.minimumTurnoutPercent, 10) + XCTAssertEqual(proposal.endDateMs, endDate) + XCTAssertEqual(proposal.endDate, Date(timeIntervalSince1970: TimeInterval(endDate) / 1000)) + XCTAssertEqual(proposal.createdAt, Date(timeIntervalSince1970: 1_700_000_000)) + } + + /// The FFI writes a proposal without a url as `"url": null`; a missing key + /// reads the same. + func testShouldDecodeAProposalWithoutAUrl() throws { + let nullURL = try decode(DecisionProposal.self, proposalJSON(url: #""url":null,"#)) + XCTAssertNil(nullURL.url) + + let absentURL = try decode(DecisionProposal.self, proposalJSON(url: "")) + XCTAssertNil(absentURL.url) + XCTAssertEqual(absentURL.title, "Raise the block size?") + } + + func testShouldDecodeNullSystemFieldsAsAbsent() throws { + let json = """ + {"id":"4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF",\ + "ownerId":"CQgw5i7y2Y4Ns6rTgV6cq7MhQ7b4oJxEf1vsaFgM2KXJ",\ + "revision":null,"createdAt":null,"updatedAt":null,\ + "title":"t","description":"d","url":null,\ + "passPercent":50,"minimumTurnoutPercent":0,"endDate":\(endDate)} + """ + let proposal = try decode(DecisionProposal.self, json) + + XCTAssertNil(proposal.revision) + XCTAssertNil(proposal.createdAtMs) + XCTAssertNil(proposal.createdAt) + XCTAssertNil(proposal.updatedAtMs) + } + + func testShouldDecodeAListingInItsOrder() throws { + let second = proposalJSON(url: "") + .replacingOccurrences( + of: "4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF", + with: "11111111111111111111111111111111" + ) + let listing = try decode( + [DecisionProposal].self, + "[\(proposalJSON(url: #""url":null,"#)),\(second)]" + ) + + XCTAssertEqual( + listing.map(\.id), + ["4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF", "11111111111111111111111111111111"] + ) + XCTAssertEqual(try decode([DecisionProposal].self, "[]"), []) + } + + func testShouldRefuseAProposalMissingARequiredField() { + let json = proposalJSON(url: "").replacingOccurrences(of: #""passPercent":60,"#, with: "") + + XCTAssertThrowsError(try decode(DecisionProposal.self, json)) { error in + guard case SDKError.serializationError = error else { + return XCTFail("expected a serialization error, got \(error)") + } + } + } + + // MARK: - Poll state + + func testShouldDecodeAStartedPollWithItsTallies() throws { + let json = """ + {"votePoll":\(fractionPollJSON),\ + "status":"started","startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3},\ + "yesVotingPower":5,"noVotingPower":1,"abstainVotingPower":4} + """ + let state = try decode(YesNoVotePollState.self, json) + + XCTAssertEqual( + state.votePoll.purpose, + .decisionProposal(documentId: "4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF") + ) + XCTAssertEqual(state.votePoll.supermajorityNumerator, 60) + XCTAssertEqual(state.votePoll.supermajorityDenominator, 100) + XCTAssertEqual( + state.votePoll.minimumVotingPower, + .fractionOfTotal(numerator: 10, denominator: 100, rounding: .up) + ) + XCTAssertEqual( + state.status, + .started(startBlock: PlatformBlockInfoFixture.block(height: 7)) + ) + XCTAssertNil(state.result) + XCTAssertEqual(state.yesVotingPower, 5) + XCTAssertEqual(state.noVotingPower, 1) + XCTAssertEqual(state.abstainVotingPower, 4) + } + + /// Once finished the poll has no `startBlock`, its record is in `result`, + /// and the top-level tallies are 0 (the votes are removed). + func testShouldDecodeAFinishedPollWithItsResult() throws { + let json = """ + {"votePoll":\(fractionPollJSON),\ + "status":"finished",\ + "result":{"passed":true,"yesVotingPower":30,"noVotingPower":10,\ + "abstainVotingPower":8,"requiredVotingPower":20,\ + "startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3},\ + "finalizationBlock":{"height":9,"coreHeight":42,"timeMs":9000,"epoch":3}},\ + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """ + let state = try decode(YesNoVotePollState.self, json) + let result = try XCTUnwrap(state.result) + + XCTAssertTrue(result.passed) + XCTAssertEqual(result.yesVotingPower, 30) + XCTAssertEqual(result.noVotingPower, 10) + XCTAssertEqual(result.abstainVotingPower, 8) + XCTAssertEqual(result.requiredVotingPower, 20) + XCTAssertEqual(result.startBlock, PlatformBlockInfoFixture.block(height: 7)) + XCTAssertEqual(result.finalizationBlock, PlatformBlockInfoFixture.block(height: 9)) + XCTAssertEqual(result.finalizationBlock.time, Date(timeIntervalSince1970: 9)) + XCTAssertEqual(state.status, .finished(result)) + XCTAssertEqual(state.yesVotingPower, 0) + } + + func testShouldDecodeAnAbsoluteMinimumVotingPower() throws { + let json = """ + {"votePoll":{"$type":"yesNoVotePoll",\ + "purpose":{"$type":"decisionProposal","documentId":"4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF"},\ + "supermajorityNumerator":2,"supermajorityDenominator":3,\ + "minimumVotingPower":{"$type":"absolute","votingPower":400}},\ + "status":"started","startBlock":{"height":7,"coreHeight":42,"timeMs":7000,"epoch":3},\ + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """ + let state = try decode(YesNoVotePollState.self, json) + + XCTAssertEqual(state.votePoll.minimumVotingPower, .absolute(votingPower: 400)) + } + + func testShouldDecodeEveryRounding() throws { + for (name, rounding) in [ + ("down", VotingPowerRounding.down), + ("up", .up), + ("downPlusOne", .downPlusOne), + ("upMinusOne", .upMinusOne), + ] { + let minimum = try decode( + YesNoMinimumVotingPower.self, + #"{"$type":"fractionOfTotal","numerator":1,"denominator":3,"rounding":"\#(name)"}"# + ) + XCTAssertEqual(minimum, .fractionOfTotal(numerator: 1, denominator: 3, rounding: rounding)) + } + } + + func testShouldRefuseAnUnknownPollStatus() { + let json = """ + {"votePoll":\(fractionPollJSON),"status":"paused",\ + "yesVotingPower":0,"noVotingPower":0,"abstainVotingPower":0} + """ + + XCTAssertThrowsError(try decode(YesNoVotePollState.self, json)) + } + + func testShouldRefuseAnUnknownMinimumVotingPowerType() { + XCTAssertThrowsError( + try decode(YesNoMinimumVotingPower.self, #"{"$type":"quadratic","votingPower":1}"#) + ) + } + + // MARK: - Masternode answer and vote choice + + func testShouldDecodeAMasternodeAnswerForEveryChoice() throws { + for choice in YesNoAbstainVoteChoice.allCases { + let vote = try decode( + MasternodeYesNoVote.self, + #"{"choice":"\#(choice.rawValue)","timesVoted":2}"# + ) + XCTAssertEqual(vote, try decode( + MasternodeYesNoVote.self, + #"{"timesVoted":2,"choice":"\#(choice.rawValue)"}"# + )) + XCTAssertEqual(vote.choice, choice) + XCTAssertEqual(vote.timesVoted, 2) + } + XCTAssertThrowsError( + try decode(MasternodeYesNoVote.self, #"{"choice":"lock","timesVoted":1}"#) + ) + } + + /// The `vote_choice` byte must agree with `YesNoVoteChoiceFFI` in + /// `rs-sdk-ffi`: yes 0, no 1, abstain 2. Any other byte is refused there. + func testShouldEncodeTheVoteChoicesAsTheFFIBytes() { + XCTAssertEqual(YesNoAbstainVoteChoice.yes.ffiTag, 0) + XCTAssertEqual(YesNoAbstainVoteChoice.no.ffiTag, 1) + XCTAssertEqual(YesNoAbstainVoteChoice.abstain.ffiTag, 2) + XCTAssertEqual(YesNoAbstainVoteChoice.allCases.map(\.rawValue), ["yes", "no", "abstain"]) + } + + // MARK: - Params + + func testShouldWriteTheEndDateInMilliseconds() { + let params = DecisionProposalParams( + title: "t", + description: "d", + passPercent: 60, + minimumTurnoutPercent: 10, + endDate: Date(timeIntervalSince1970: 1_701_209_600.123_9) + ) + + XCTAssertEqual(params.endDateMs, 1_701_209_600_123) + XCTAssertNil(params.url) + + let beforeEpoch = DecisionProposalParams( + title: "t", + description: "d", + passPercent: 60, + minimumTurnoutPercent: 10, + endDate: Date(timeIntervalSince1970: -1) + ) + XCTAssertEqual(beforeEpoch.endDateMs, 0) + } +} + +/// Blocks as the payloads above write them: core height 42, epoch 3, a second +/// of block time per height. +private enum PlatformBlockInfoFixture { + static func block(height: UInt64) -> PlatformBlockInfo { + // Decoded rather than built, as `PlatformBlockInfo` has no public + // memberwise initializer. + let json = #"{"height":\#(height),"coreHeight":42,"timeMs":\#(height * 1000),"epoch":3}"# + return try! DecisionProposalJSON.decode(PlatformBlockInfo.self, from: json) + } +} From 86c2bce6efa21c13a437983df7206a021cfadbe4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 10:59:00 +0300 Subject: [PATCH 2/5] fix(sdk): end-date test reads the poll's $type; show an unreadable poll state Review fixes: - The rs-sdk-ffi voting integration test checked each listed poll for the old "end_time" key; it checks the canonical JSON's "$type" now. - SwiftExampleApp's proposal detail tracks a failed poll state read on its own: an earlier state stays on screen, and without one the poll shows as unreadable instead of loading forever. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk-ffi/tests/integration_tests/voting.rs | 4 ++-- .../DecisionProposalDetailView.swift | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk-ffi/tests/integration_tests/voting.rs b/packages/rs-sdk-ffi/tests/integration_tests/voting.rs index c21d0ed5383..3d51b0b7204 100644 --- a/packages/rs-sdk-ffi/tests/integration_tests/voting.rs +++ b/packages/rs-sdk-ffi/tests/integration_tests/voting.rs @@ -35,10 +35,10 @@ fn test_voting_vote_polls_by_end_date() { let vote_polls = group.get("vote_polls").unwrap(); assert!(vote_polls.is_array(), "Vote polls should be an array"); - // Each vote poll should have end_time + // Each vote poll is its canonical JSON, tagged with its kind if let Some(polls_array) = vote_polls.as_array() { for poll in polls_array { - assert!(poll.get("end_time").is_some(), "Poll should have end_time"); + assert!(poll.get("$type").is_some(), "Poll should have $type"); } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift index af009332917..d85bbf88033 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift @@ -20,6 +20,10 @@ struct DecisionProposalDetailView: View { /// `true` once the poll state has been read, so "never opened" is not /// shown while it is still loading. @State private var pollStateLoaded = false + /// `true` when the last read of the poll state failed. An earlier state + /// stays on screen; without one, the poll shows as unavailable rather than + /// loading or never opened. + @State private var pollStateLoadFailed = false @State private var pollId: String? @State private var isRefreshing = false @State private var errorMessage: String? @@ -164,7 +168,7 @@ struct DecisionProposalDetailView: View { @ViewBuilder private var pollSection: some View { Section { - if !pollStateLoaded { + if !pollStateLoaded && !pollStateLoadFailed { HStack { ProgressView() Text("Loading poll…") @@ -200,6 +204,10 @@ struct DecisionProposalDetailView: View { blockRow(label: "Opened", block: result.startBlock) blockRow(label: "Closed", block: result.finalizationBlock) } + } else if pollStateLoadFailed { + Text("The poll state could not be read.") + .font(.caption) + .foregroundColor(.secondary) } else { Text("Platform has no poll for this proposal.") .font(.caption) @@ -341,7 +349,9 @@ struct DecisionProposalDetailView: View { case .success(let value): pollState = value pollStateLoaded = true + pollStateLoadFailed = false case .failure(let error): + pollStateLoadFailed = true failures.append("Poll state: \(error.localizedDescription)") } switch await pollIdValue { From b6dc1a6834bc38a96fd321c9b864da8b479b7746 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 16:38:15 +0300 Subject: [PATCH 3/5] fix(sdk): create and edit decision proposals in the platform wallet Review fixes: - The platform wallet owns proposal create and edit: IdentityWallet::create_decision_proposal_with_signer and edit_decision_proposal_with_signer, with thin C functions platform_wallet_decision_proposal_create / _edit. The edit reads the proposal once, applies the new text and puts the revision after the one read, so a concurrent edit makes Platform refuse it (40106) instead of being overwritten. Swift and Kotlin call these once each; rs-sdk-ffi's build / properties_json / fetch_document / build_edit hand-off is gone. - Swift refuses proposal strings with an embedded NUL before they reach C, and the edit form reloads the proposal after a refused edit. - Kotlin masks the voting private key (the contest screen too) and holds FLAG_SECURE while the vote dialog is open; the create runs in a view model and Back is held while it runs, so leaving the screen cannot lose a paid create. Co-Authored-By: Claude Opus 5.5 --- .../kotlin-sdk/KotlinExampleApp/TEST_PLAN.md | 8 +- .../ui/identity/ContestDetailScreen.kt | 13 +- .../identity/DecisionProposalDetailScreen.kt | 74 +- .../ui/identity/DecisionProposalsScreen.kt | 122 +++- .../CreateDecisionProposalViewModelTest.kt | 115 +++ packages/kotlin-sdk/PARITY.md | 11 +- .../dashsdk/DecisionProposalsFfiTest.kt | 115 ++- .../dashsdk/ffi/DecisionProposalsNative.kt | 90 +-- .../dashsdk/voting/DecisionProposalModels.kt | 30 +- .../dashsdk/voting/DecisionProposals.kt | 177 ++--- .../dashsdk/wallet/PlatformWalletManager.kt | 11 +- .../voting/DecisionProposalModelsTest.kt | 21 +- .../src/decision_proposal.rs | 602 +++++++++++++++ .../rs-platform-wallet-ffi/src/document.rs | 4 +- packages/rs-platform-wallet-ffi/src/error.rs | 5 + packages/rs-platform-wallet-ffi/src/lib.rs | 2 + packages/rs-platform-wallet/src/error.rs | 6 + .../identity/network/decision_proposal.rs | 619 ++++++++++++++++ .../src/wallet/identity/network/document.rs | 149 ++-- .../src/wallet/identity/network/mod.rs | 1 + .../rs-sdk-ffi/src/decision_proposal/json.rs | 23 - .../rs-sdk-ffi/src/decision_proposal/mod.rs | 40 +- .../src/decision_proposal/transitions.rs | 690 +----------------- packages/rs-sdk-ffi/src/document/mod.rs | 5 +- .../src/decision_proposals.rs | 464 ++++++------ ...nagedPlatformWalletDecisionProposals.swift | 252 +++++-- .../Voting/DecisionProposal.swift | 46 +- .../Voting/SDK+DecisionProposals.swift | 242 ++---- .../DecisionProposalDetailView.swift | 5 +- .../DecisionProposalFormViews.swift | 108 +-- .../DecisionProposalCStringTests.swift | 99 +++ 31 files changed, 2538 insertions(+), 1611 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/identity/CreateDecisionProposalViewModelTest.kt create mode 100644 packages/rs-platform-wallet-ffi/src/decision_proposal.rs create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/decision_proposal.rs create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalCStringTests.swift diff --git a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md index ac0940ba05c..f4ec7a4483e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md +++ b/packages/kotlin-sdk/KotlinExampleApp/TEST_PLAN.md @@ -169,7 +169,7 @@ 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). | @@ -177,10 +177,10 @@ Most Platform actions have hard preconditions. Establish these fixtures before s | 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`. Pays the decision proposal fund (0.5 Dash) plus fees. 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-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.`): 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`. Accepted for a day after creation; an edit that changes nothing is refused before anything is broadcast. | -| 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`. **Requires masternode voting credentials**; a vote from a regular wallet is refused (no voter identity). | +| 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` diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt index 4943f7bb60b..56422720d65 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/ContestDetailScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -30,6 +31,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -317,7 +320,11 @@ fun ContestDetailScreen( /** * Modal for picking a vote choice and entering masternode credentials — - * port of `CastVoteSheet` in `ContestDetailView.swift`. + * port of `CastVoteSheet` in `ContestDetailView.swift`. The voting private + * key is masked as it is typed and screen capture is blocked while the + * dialog is open, as in the decision proposal vote dialog + * ([HoldSecureWindow], [secureDialogProperties]); the dialog cannot be + * dismissed while the vote is broadcast. */ @Composable private fun CastVoteDialog( @@ -335,8 +342,10 @@ private fun CastVoteDialog( val options = contenders.map { VoteSelection.Contender(it.identityIdBase58) } + listOf(VoteSelection.Abstain, VoteSelection.Lock) + HoldSecureWindow() AlertDialog( onDismissRequest = onDismiss, + properties = secureDialogProperties(dismissible = !isCasting), title = { Text("Cast Vote · $contestName.dash") }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -375,6 +384,8 @@ private fun CastVoteDialog( singleLine = true, textStyle = MaterialTheme.typography.bodySmall .copy(fontFamily = FontFamily.Monospace), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth().testTag("contestDetail.votingKey"), ) Text( diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt index 4f4b28d3889..c130112b7e8 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,7 +33,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.SecureFlagPolicy import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import java.util.Date @@ -45,15 +49,16 @@ import org.dashfoundation.dashsdk.voting.YesNoAbstainVoteChoice import org.dashfoundation.dashsdk.voting.YesNoMinimumVotingPower import org.dashfoundation.dashsdk.voting.YesNoVotePollState import org.dashfoundation.dashsdk.voting.YesNoVotePollStatus +import org.dashfoundation.example.LocalSecureScreen import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.ui.components.AccessiblePicker import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent import org.dashfoundation.example.ui.components.SubmitButton -import org.dashfoundation.example.ui.contracts.resolveSigning import org.dashfoundation.example.util.Base58 import org.dashfoundation.example.util.formatDate +import org.dashfoundation.example.util.toHex import org.dashfoundation.example.util.truncateMiddle /** @@ -377,6 +382,9 @@ private fun PollStateRows(state: YesNoVotePollState) { * in `DecisionProposalDetailView.swift`. Votes through * `Sdk.decisionProposals.castVote`, and reads the masternode's current answer through * `Sdk.decisionProposals.masternodeVote`. + * + * The voting private key is masked as it is typed, and screen capture is blocked while the + * dialog is open (see [HoldSecureWindow]). The dialog cannot be dismissed while a call runs. */ @Composable private fun DecisionProposalVoteDialog( @@ -387,6 +395,7 @@ private fun DecisionProposalVoteDialog( val appState = LocalAppState.current val scope = rememberCoroutineScope() val sdk by appState.sdk.collectAsStateWithLifecycle() + HoldSecureWindow() var choice by remember { mutableStateOf(YesNoAbstainVoteChoice.YES) } var proTxHashHex by remember { mutableStateOf("") } @@ -397,6 +406,7 @@ private fun DecisionProposalVoteDialog( AlertDialog( onDismissRequest = { if (!isBusy) onDismiss() }, + properties = secureDialogProperties(dismissible = !isBusy), title = { Text("Vote · ${truncateMiddle(proposal.title, 20, 8)}") }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -427,6 +437,9 @@ private fun DecisionProposalVoteDialog( enabled = !isBusy, textStyle = MaterialTheme.typography.bodySmall .copy(fontFamily = FontFamily.Monospace), + // KeyboardType.Password only configures the keyboard; the + // transformation masks what the field shows. + visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), modifier = Modifier.fillMaxWidth().testTag("decisionProposalVote.votingKey"), ) @@ -527,6 +540,32 @@ private fun DecisionProposalVoteDialog( ) } +/** + * Block screenshots, screen recording and the recents thumbnail of the app window while the + * calling composable is shown (the app's FLAG_SECURE toggle, [LocalSecureScreen]). Shared + * with the contested-name vote dialog, which takes the same voting private key. + */ +@Composable +internal fun HoldSecureWindow() { + val setSecure = LocalSecureScreen.current + DisposableEffect(Unit) { + setSecure(true) + onDispose { setSecure(false) } + } +} + +/** + * The properties of a dialog that takes a voting private key. A dialog is a window of its + * own: FLAG_SECURE on the app window reaches it only when the dialog reads its parent's + * flags, so the dialog sets the flag itself. [dismissible] false holds Back and taps outside + * while a call runs, so the dialog (and the call in its scope) stays. + */ +internal fun secureDialogProperties(dismissible: Boolean): DialogProperties = DialogProperties( + dismissOnBackPress = dismissible, + dismissOnClickOutside = dismissible, + securePolicy = SecureFlagPolicy.SecureOn, +) + /** Display name of a vote answer. */ private fun YesNoAbstainVoteChoice.label(): String = when (this) { YesNoAbstainVoteChoice.YES -> "Yes" @@ -537,9 +576,15 @@ private fun YesNoAbstainVoteChoice.label(): String = when (this) { /** * Gives a proposal a local identity filed new text: port of `EditDecisionProposalView` * (`DecisionProposalFormViews.swift`). Submits through - * `PlatformWalletManager.decisionProposals.edit`: rs-sdk reads the stored proposal and - * applies the edit, refusing one that changes nothing, and the platform wallet replaces it, - * signing with the key [resolveSigning] picks, as the document replace form does. + * `PlatformWalletManager.decisionProposals.edit`, one platform wallet call that reads the + * stored proposal, applies the edit (rs-sdk refuses one that changes nothing), signs with a + * key it picks and waits for Platform to confirm. + * + * The dialog cannot be dismissed (Back or a tap outside) while the edit runs, so the paid + * replace running in its scope is not cancelled with it. Should the dialog still leave + * composition mid-edit (a rotation), the detail screen reads the proposal again, and an edit + * repeating one that landed changes nothing, which rs-sdk refuses before anything is + * broadcast. */ @Composable private fun EditDecisionProposalDialog( @@ -548,10 +593,9 @@ private fun EditDecisionProposalDialog( onDismiss: () -> Unit, onEdited: (String) -> Unit, ) { - val appState = LocalAppState.current val container = LocalAppContainer.current val scope = rememberCoroutineScope() - val sdk by appState.sdk.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() var title by remember { mutableStateOf(proposal.title) } var description by remember { mutableStateOf(proposal.description) } @@ -561,6 +605,10 @@ private fun EditDecisionProposalDialog( AlertDialog( onDismissRequest = { if (!isSubmitting) onDismiss() }, + properties = DialogProperties( + dismissOnBackPress = !isSubmitting, + dismissOnClickOutside = !isSubmitting, + ), title = { Text("Edit Proposal") }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -608,9 +656,14 @@ private fun EditDecisionProposalDialog( }, confirmButton = { TextButton( - enabled = !isSubmitting && sdk != null, + enabled = !isSubmitting && manager != null, onClick = { - val currentSdk = sdk ?: return@TextButton + val mgr = manager ?: return@TextButton + val wallet = owner.walletId?.toHex()?.let { mgr.wallets.value[it] } + if (wallet == null) { + error = "The proposer's wallet is not loaded in the wallet manager." + return@TextButton + } val proposalIdBytes = Base58.decodeIdentifier(proposal.id) if (proposalIdBytes == null || proposalIdBytes.size != 32) { error = "The proposal id is not a 32-byte base58 identifier." @@ -633,14 +686,11 @@ private fun EditDecisionProposalDialog( error = null scope.launch { try { - val (walletHandle, mgr, signingKeyId) = resolveSigning(container, owner) mgr.decisionProposals.edit( - sdk = currentSdk, - walletHandle = walletHandle, + walletHandle = wallet.handle, ownerId = owner.identityId, proposalId = proposalIdBytes, edit = edit, - signingKeyId = signingKeyId, signerHandle = mgr.signerHandle, ) onEdited("Edit accepted by Platform.") diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt index 0934a138415..ff1ba332903 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalsScreen.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.identity +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -31,15 +32,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import java.util.Date +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.dashfoundation.dashsdk.Sdk import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity @@ -54,7 +60,6 @@ import org.dashfoundation.example.ui.components.ErrorAlertDialog import org.dashfoundation.example.ui.components.FormSection import org.dashfoundation.example.ui.components.LabeledContent import org.dashfoundation.example.ui.components.SubmitButton -import org.dashfoundation.example.ui.contracts.parseDocumentId import org.dashfoundation.example.ui.tokens.displayLabel import org.dashfoundation.example.util.Base58 import org.dashfoundation.example.util.formatDate @@ -319,22 +324,72 @@ private fun DecisionProposalRow(proposal: DecisionProposal, onClick: () -> Unit) ) } +/** + * The submission of [CreateDecisionProposalScreen], held by the screen's back-stack entry + * rather than by its composition. + * + * The create pays the decision proposal fund. A coroutine of the composition is cancelled + * whenever the screen leaves composition (a rotation, a switch to another bottom tab, an + * invitation link opening the DashPay tab), which would drop a create Platform already + * accepted and invite a second, paid retry. The back-stack entry outlives all of those (a + * tab switch saves it together with its ViewModels), so the create runs to its end and the + * screen shows the outcome when it composes again. Back, which would clear the entry, is + * held while the create runs ([CreateDecisionProposalScreen]). + */ +class CreateDecisionProposalViewModel : ViewModel() { + + /** Whether a create is running. */ + var isSubmitting by mutableStateOf(false) + private set + + /** The message of the last failure, form errors included, until it is dismissed. */ + var error by mutableStateOf(null) + + /** The created proposal's id, base58, once Platform confirmed the create. */ + var createdProposalId by mutableStateOf(null) + private set + + /** + * Run [create], which returns the created proposal's id, unless a create is running or + * one already succeeded: the form is never submitted twice. + */ + fun submit(create: suspend () -> String) { + if (isSubmitting || createdProposalId != null) return + isSubmitting = true + error = null + viewModelScope.launch { + try { + createdProposalId = create() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + error = e.message ?: "Create failed" + } finally { + isSubmitting = false + } + } + } +} + /** * Files a new decision proposal for an identity held by a loaded wallet: port of * `CreateDecisionProposalView` (`DecisionProposalFormViews.swift`). * - * Submits through `PlatformWalletManager.decisionProposals.create`, which has rs-sdk build - * the proposal and the platform wallet put it; the create pays the decision proposal fund - * from the proposer's credits. Nothing in the form is checked here: Platform refuses a rule + * Submits through `PlatformWalletManager.decisionProposals.create`, one platform wallet + * call that builds the proposal, pays the decision proposal fund from the proposer's + * credits and waits for Platform to confirm. The submission lives in + * [CreateDecisionProposalViewModel] and Back is held while it runs, so leaving the screen + * never cancels a paid create. Nothing in the form is checked here: Platform refuses a rule * outside its limits. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun CreateDecisionProposalScreen(navController: NavHostController) { +fun CreateDecisionProposalScreen( + navController: NavHostController, + submission: CreateDecisionProposalViewModel = viewModel(), +) { val appState = LocalAppState.current val container = LocalAppContainer.current - val scope = rememberCoroutineScope() - val sdk by appState.sdk.collectAsStateWithLifecycle() val network by appState.currentNetwork.collectAsStateWithLifecycle() val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() // Identities on the active network held by a loaded wallet, so the @@ -347,16 +402,17 @@ fun CreateDecisionProposalScreen(navController: NavHostController) { LaunchedEffect(identities) { if (owner == null) owner = identities.firstOrNull() } - var title by remember { mutableStateOf("") } - var description by remember { mutableStateOf("") } - var url by remember { mutableStateOf("") } - var passPercent by remember { mutableStateOf("60") } - var turnoutPercent by remember { mutableStateOf("10") } - var endsInDays by remember { mutableStateOf("14") } + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var url by rememberSaveable { mutableStateOf("") } + var passPercent by rememberSaveable { mutableStateOf("60") } + var turnoutPercent by rememberSaveable { mutableStateOf("10") } + var endsInDays by rememberSaveable { mutableStateOf("14") } - var isSubmitting by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var createdProposalId by remember { mutableStateOf(null) } + val isSubmitting = submission.isSubmitting + // System Back would pop the entry and clear the submission with it; the toolbar Back + // is disabled for the same reason. + BackHandler(enabled = isSubmitting) {} Scaffold( topBar = { @@ -382,7 +438,7 @@ fun CreateDecisionProposalScreen(navController: NavHostController) { .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - val createdId = createdProposalId + val createdId = submission.createdProposalId if (createdId != null) { FormSection(title = "Proposal Created") { Text( @@ -502,10 +558,9 @@ fun CreateDecisionProposalScreen(navController: NavHostController) { SubmitButton( text = if (isSubmitting) "Broadcasting…" else "Create / Broadcast", isLoading = isSubmitting, - enabled = sdk != null && manager != null && owner != null, + enabled = manager != null && owner != null, modifier = Modifier.fillMaxWidth().testTag("createDecisionProposal.submitButton"), ) { - val currentSdk = sdk ?: return@SubmitButton val mgr = manager ?: return@SubmitButton val proposer = owner ?: return@SubmitButton // Form parsing only: the limits on these values are Platform's. @@ -513,12 +568,12 @@ fun CreateDecisionProposalScreen(navController: NavHostController) { val turnout = turnoutPercent.trim().toIntOrNull() val days = endsInDays.trim().toLongOrNull() if (pass == null || turnout == null || days == null) { - error = "The pass percent, turnout and days must be whole numbers." + submission.error = "The pass percent, turnout and days must be whole numbers." return@SubmitButton } val wallet = proposer.walletId?.toHex()?.let { mgr.wallets.value[it] } if (wallet == null) { - error = "The proposer's wallet is not loaded in the wallet manager." + submission.error = "The proposer's wallet is not loaded in the wallet manager." return@SubmitButton } val params = DecisionProposalParams( @@ -529,28 +584,19 @@ fun CreateDecisionProposalScreen(navController: NavHostController) { minimumTurnoutPercent = turnout, endDateMs = System.currentTimeMillis() + days * MILLIS_PER_DAY, ) - isSubmitting = true - scope.launch { - try { - val json = mgr.decisionProposals.create( - sdk = currentSdk, - walletHandle = wallet.handle, - ownerId = proposer.identityId, - params = params, - signerHandle = mgr.signerHandle, - ) - createdProposalId = parseDocumentId(json) ?: "(id in JSON)" - } catch (e: Exception) { - error = e.message ?: "Create failed" - } finally { - isSubmitting = false - } + submission.submit { + mgr.decisionProposals.create( + walletHandle = wallet.handle, + ownerId = proposer.identityId, + params = params, + signerHandle = mgr.signerHandle, + ).id } } } } - ErrorAlertDialog(message = error, onDismiss = { error = null }) + ErrorAlertDialog(message = submission.error, onDismiss = { submission.error = null }) } private const val MILLIS_PER_DAY = 86_400_000L diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/identity/CreateDecisionProposalViewModelTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/identity/CreateDecisionProposalViewModelTest.kt new file mode 100644 index 00000000000..b80c4841861 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/identity/CreateDecisionProposalViewModelTest.kt @@ -0,0 +1,115 @@ +package org.dashfoundation.example.ui.identity + +import androidx.lifecycle.ViewModelStore +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Pins the fix for the "a paid create is cancelled when the screen goes away" review + * finding: [CreateDecisionProposalViewModel] runs the create in its own scope, which lives + * as long as the screen's back-stack entry rather than its composition, submits it at most + * once, and reports a failure without a cancellation passing for one. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CreateDecisionProposalViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before + fun setMain() { + Dispatchers.setMain(dispatcher) + } + + @After + fun resetMain() { + Dispatchers.resetMain() + } + + @Test + fun shouldRecordTheCreatedIdAndNeverSubmitTwice() = runTest(dispatcher) { + val submission = CreateDecisionProposalViewModel() + val confirmed = CompletableDeferred() + var creates = 0 + + submission.submit { + creates++ + confirmed.await() + } + advanceUntilIdle() + assertTrue(submission.isSubmitting) + + // A second tap while the first create runs is ignored. + submission.submit { + creates++ + "second" + } + advanceUntilIdle() + assertEquals(1, creates) + + confirmed.complete("8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN") + advanceUntilIdle() + assertFalse(submission.isSubmitting) + assertEquals("8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN", submission.createdProposalId) + assertNull(submission.error) + + // So is one after the create succeeded. + submission.submit { + creates++ + "third" + } + advanceUntilIdle() + assertEquals(1, creates) + assertEquals("8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN", submission.createdProposalId) + } + + @Test + fun shouldRecordAFailureAndAllowAnotherSubmit() = runTest(dispatcher) { + val submission = CreateDecisionProposalViewModel() + + submission.submit { throw IllegalStateException("Platform refused the end date") } + advanceUntilIdle() + assertFalse(submission.isSubmitting) + assertEquals("Platform refused the end date", submission.error) + assertNull(submission.createdProposalId) + + submission.submit { "8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN" } + advanceUntilIdle() + assertNull(submission.error) + assertEquals("8vLzWy3oPYAwJ9W8TjWZ3vSJMy7bDqyuvbMvzQMxVjPN", submission.createdProposalId) + } + + /** + * Only clearing the back-stack entry's store stops a running create, and the + * cancellation is not reported as a failure. + */ + @Test + fun shouldStopOnlyWhenTheEntryIsClearedWithoutReportingAFailure() = runTest(dispatcher) { + val store = ViewModelStore() + val submission = CreateDecisionProposalViewModel() + store.put("createDecisionProposal", submission) + val confirmed = CompletableDeferred() + + submission.submit { confirmed.await() } + advanceUntilIdle() + assertTrue(submission.isSubmitting) + + store.clear() + advanceUntilIdle() + assertFalse(submission.isSubmitting) + assertNull(submission.error) + assertNull(submission.createdProposalId) + } +} diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 5ef8bf20fe6..fffebbc4a8c 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -123,17 +123,18 @@ verbatim. Documented deviations from iOS are listed after the table. ## Views/DecisionProposals/ Decision proposals (protocol version 15). The Kotlin SDK bridges every -`dash_sdk_decision_proposal_*` function one JNI export each +`dash_sdk_decision_proposal_*` function and the platform wallet's +`platform_wallet_decision_proposal_create` / `_edit` one JNI export each (`rs-unified-sdk-jni/src/decision_proposals.rs`); `Sdk.decisionProposals` -reads and votes, `PlatformWalletManager.decisionProposals` sequences the -create and the edit in Kotlin. Compile-verified; the device run waits on a +reads and votes, and `PlatformWalletManager.decisionProposals` creates and +edits, one native call each. Compile-verified; the device run waits on a rebuilt `libdash_sdk_jni.so` and a network at protocol version 15. | Swift file | Android file / route | Status | | --- | --- | --- | | DecisionProposalsView.swift | ui/identity/DecisionProposalsScreen.kt · `DecisionProposalsList` | ported: Open / Mine / Newest listings, paged, over `Sdk.decisionProposals.listByEndDate` / `listByOwner` / `listByCreatedAt`; reached from the Platform Queries list (`votingQueries.decisionProposalsLink`) as on iOS | -| DecisionProposalDetailView.swift | ui/identity/DecisionProposalDetailScreen.kt · `DecisionProposalDetail` | ported: proposal, rule, poll state (open tallies or finished result) and poll id; masternode vote and answer lookup over `castVote` / `masternodeVote` with the contest screen's credential form | -| DecisionProposalFormViews.swift | ui/identity/DecisionProposalsScreen.kt · `CreateDecisionProposal` (create); ui/identity/DecisionProposalDetailScreen.kt (edit dialog) | ported: create through `PlatformWalletManager.decisionProposals.create` (build, properties JSON, free, then the wallet's document create); edit through `.edit` (fetch, build the edit, properties JSON, free, then the wallet's document replace). The end date is entered as days from now rather than with a date picker | +| DecisionProposalDetailView.swift | ui/identity/DecisionProposalDetailScreen.kt · `DecisionProposalDetail` | ported: proposal, rule, poll state (open tallies or finished result) and poll id; masternode vote and answer lookup over `castVote` / `masternodeVote` with the contest screen's credential form (voting key masked, FLAG_SECURE held while the dialog is open) | +| DecisionProposalFormViews.swift | ui/identity/DecisionProposalsScreen.kt · `CreateDecisionProposal` (create); ui/identity/DecisionProposalDetailScreen.kt (edit dialog) | ported: create through `PlatformWalletManager.decisionProposals.create` (`platform_wallet_decision_proposal_create`, returning the proposal id and the confirmed document); edit through `.edit` (`platform_wallet_decision_proposal_edit`; the wallet picks the signing key and a concurrent edit is refused with consensus code 40106). The create runs in a back-stack-entry ViewModel with Back held while it runs, and the edit dialog cannot be dismissed while it runs. The end date is entered as days from now rather than with a date picker | ## Views/Components/ diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt index fe89c94bf34..b2de2e261ee 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/DecisionProposalsFfiTest.kt @@ -1,6 +1,7 @@ package org.dashfoundation.dashsdk import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.ffi.DecisionProposalsNative import org.dashfoundation.dashsdk.ffi.NativeLoader @@ -8,6 +9,7 @@ import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.persistence.toBase58String import org.dashfoundation.dashsdk.voting.YesNoAbstainVoteChoice import org.junit.After +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -18,18 +20,23 @@ import org.junit.Test import org.junit.runner.RunWith /** - * Round trips of the decision proposal JNI exports through `rs-sdk-ffi` on a device: the - * symbol names, the argument marshalling and the error codes Kotlin maps. - * Every call here is refused before anything reaches the network (rs-sdk-ffi reads every - * argument first), so no network or protocol version 15 is needed: `createTrusted` only - * builds the client. The flows that reach Platform (build, fetch, vote) need a network at - * protocol version 15 and are exercised through KotlinExampleApp. + * Round trips of the decision proposal JNI exports through `rs-sdk-ffi` and + * `platform-wallet-ffi` on a device: the symbol names, the argument marshalling and the + * error codes Kotlin maps. Every call here is refused before anything reaches the network + * (the JNI layer and the C functions read every argument first), so no network, wallet or + * protocol version 15 is needed: `createTrusted` only builds the client. The flows that + * reach Platform (create, edit, fetch, vote) need a network at protocol version 15 and are + * exercised through KotlinExampleApp. */ @RunWith(AndroidJUnit4::class) class DecisionProposalsFfiTest { /** A well-formed 32-byte id that names no proposal. */ - private val proposalId = ByteArray(32) { 0x11 }.toBase58String() + private val proposalIdBytes = ByteArray(32) { 0x11 } + private val proposalId = proposalIdBytes.toBase58String() + + /** A well-formed 32-byte identity id. */ + private val ownerId = ByteArray(32) { 0x22 } private var handle = 0L @@ -61,53 +68,53 @@ class DecisionProposalsFfiTest { assertTrue(DecisionProposalsNative.documentTypeName().isNotEmpty()) } - @Test - fun shouldFreeANullDocumentHandleAsANoOp() { - DecisionProposalsNative.documentFree(0L) - } - - /** rs-sdk-ffi refuses null document handles and bad ids with InvalidParameter (1). */ + /** rs-sdk-ffi refuses ids that are not base58 with InvalidParameter (1). */ @Test fun shouldRefuseBadArgumentsBeforeTheNetwork() { - assertCode(1) { DecisionProposalsNative.propertiesJson(0L) } - assertCode(1) { - DecisionProposalsNative.buildEdit( - 0L, - DecisionProposalsNative.contractId(), - DecisionProposalsNative.documentTypeName(), - "new title", - null, - null, - false, - ) - } assertCode(1) { DecisionProposalsNative.fetch(handle, "0OIl") } - assertCode(1) { DecisionProposalsNative.fetchDocument(handle, "0OIl") } assertCode(1) { DecisionProposalsNative.getVotePollId(handle, "0OIl") } assertCode(1) { DecisionProposalsNative.getVotePollState(handle, "0OIl") } assertCode(1) { DecisionProposalsNative.listByOwner(handle, "0OIl", 0, null) } assertCode(1) { DecisionProposalsNative.listByCreatedAt(handle, 0, "0OIl") } - assertCode(1) { - DecisionProposalsNative.build(handle, "0OIl", "title", "description", null, 60, 10, 1L) - } } /** The JNI layer refuses a value the C type cannot hold instead of wrapping it. */ @Test fun shouldRefuseNumbersOutsideTheCTypes() { - assertCode(1) { - DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 300, 10, 1L) - } - assertCode(1) { - DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 60, -1, 1L) - } - assertCode(1) { - DecisionProposalsNative.build(handle, proposalId, "title", "description", null, 60, 10, -1L) - } + assertCode(1) { create(passPercent = 300) } + assertCode(1) { create(minimumTurnoutPercent = -1) } + assertCode(1) { create(endDateMs = -1L) } assertCode(1) { DecisionProposalsNative.listByEndDate(handle, 0L, 0L, -1, null) } assertCode(1) { DecisionProposalsNative.listByEndDate(handle, -1L, 0L, 0, null) } } + /** + * The wallet writes read exactly 32 bytes behind each id, and the create checks the + * array its id is written back to before anything is broadcast. + */ + @Test + fun shouldRefuseAWalletWriteIdThatIsNot32Bytes() { + assertCode(1) { create(outProposalId = ByteArray(31)) } + assertCode(1) { create(ownerId = ByteArray(33)) } + assertCode(1) { edit(ownerId = ByteArray(31)) } + assertCode(1) { edit(proposalId = ByteArray(33)) } + } + + /** + * Both wallet writes reach `platform-wallet-ffi`, which refuses a null signer + * (`ErrorNullPointer`, 3) before it looks the wallet up; the code arrives shifted by the + * platform wallet offset, and no id is written back. + */ + @Test + fun shouldReachThePlatformWalletForTheWrites() { + val nullPointer = DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 3 + val outProposalId = ByteArray(32) + assertCode(nullPointer) { create(outProposalId = outProposalId) } + assertArrayEquals(ByteArray(32), outProposalId) + assertCode(nullPointer) { edit() } + assertCode(nullPointer) { edit(url = "https://example.com", removeUrl = true) } + } + /** The C functions read exactly 32 bytes behind these pointers. */ @Test fun shouldRefuseAProTxHashOrVotingKeyThatIsNot32Bytes() { @@ -144,6 +151,38 @@ class DecisionProposalsFfiTest { } } + /** `create` on no wallet and no signer, with valid arguments unless overridden. */ + private fun create( + ownerId: ByteArray = this.ownerId, + passPercent: Int = 60, + minimumTurnoutPercent: Int = 10, + endDateMs: Long = 1L, + outProposalId: ByteArray = ByteArray(32), + ) { + DecisionProposalsNative.create( + 0L, + ownerId, + "title", + "description", + null, + passPercent, + minimumTurnoutPercent, + endDateMs, + 0L, + outProposalId, + ) + } + + /** `edit` on no wallet and no signer, with valid arguments unless overridden. */ + private fun edit( + ownerId: ByteArray = this.ownerId, + proposalId: ByteArray = proposalIdBytes, + url: String? = null, + removeUrl: Boolean = false, + ) { + DecisionProposalsNative.edit(0L, ownerId, proposalId, "new title", null, url, removeUrl, 0L) + } + private fun assertCode(code: Int, call: () -> Unit): DashSDKException { val error = assertThrows(DashSDKException::class.java) { call() } assertEquals(error.message, code, error.code) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt index 9d2b847d1a0..5e749dda0a7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt @@ -1,32 +1,18 @@ package org.dashfoundation.dashsdk.ffi -/** - * What [DecisionProposalsNative.build] returns: the built proposal document's handle and - * the 32-byte entropy `dash_sdk_decision_proposal_build` drew for it. Constructed by - * `rs-unified-sdk-jni` with the descriptor `(J[B)V`. - * - * Native frees [documentHandle] itself when it cannot construct or return this object; - * once Kotlin holds it, Kotlin owns the handle and frees it with - * [DecisionProposalsNative.documentFree]. - */ -class DecisionProposalBuildNativeResult( - @JvmField val documentHandle: Long, - @JvmField val entropy: ByteArray, -) - /** * Raw JNI surface for decision proposals (protocol version 15): mirrors - * `rs-unified-sdk-jni/src/decision_proposals.rs`, one export per - * `dash_sdk_decision_proposal_*` function of `rs-sdk-ffi`, plus the document free its - * handles need. + * `rs-unified-sdk-jni/src/decision_proposals.rs`, one export per C function: each + * `dash_sdk_decision_proposal_*` function of `rs-sdk-ffi` (the contract's names, the reads + * and the masternode vote), and `platform_wallet_decision_proposal_create` / `_edit` of + * `platform-wallet-ffi` (the create and the edit). * * Internal: the public API is [org.dashfoundation.dashsdk.voting.DecisionProposals] (reads * and the masternode vote) and [org.dashfoundation.dashsdk.voting.DecisionProposalTransactions] - * (create and edit), which sequence these calls; no export stitches two C calls together. - * Handles are raw Rust pointers as [Long]: `sdk` is `Sdk.handle`, and a document handle - * from [build], [fetchDocument] or [buildEdit] is freed exactly once with [documentFree]. - * Ids are base58 strings except where a `ByteArray` is named. Errors throw - * [DashSDKException]. + * (create and edit), one native call each; no export stitches two C calls together. `sdk` + * is `Sdk.handle`, `walletHandle` a `ManagedPlatformWallet` handle and `signerHandle` the + * manager's `PlatformWalletManager.signerHandle`. Ids are base58 strings except where a + * `ByteArray` is named. Errors throw [DashSDKException]. */ internal object DecisionProposalsNative { @@ -40,60 +26,58 @@ internal object DecisionProposalsNative { external fun documentTypeName(): String /** - * Build a decision proposal of [ownerIdentityId] asking [title], with its rule. Nothing - * is broadcast. Bridges `dash_sdk_decision_proposal_build`. + * Create + broadcast a decision proposal of [ownerId] asking [title], with its rule, + * signed via [signerHandle]. Bridges `platform_wallet_decision_proposal_create`: the + * wallet builds the proposal, whose create states the decision proposal fund (paid by + * the owner on top of the fees), picks the signing key, broadcasts and waits for + * Platform to confirm. * + * @param ownerId the proposer's 32-byte identity id. * @param url null for none. * @param passPercent must fit a `u8`; Platform's limits apply. * @param minimumTurnoutPercent must fit a `u8`; Platform's limits apply. * @param endDateMs milliseconds since the Unix epoch; must be non-negative. - * @return the document handle (free with [documentFree]) and its entropy. + * @param outProposalId a `ByteArray(32)`, checked before anything is broadcast, that + * receives the proposal's id. + * @return the confirmed document's canonical JSON. */ - external fun build( - sdk: Long, - ownerIdentityId: String, + external fun create( + walletHandle: Long, + ownerId: ByteArray, title: String, description: String, url: String?, passPercent: Int, minimumTurnoutPercent: Int, endDateMs: Long, - ): DecisionProposalBuildNativeResult - - /** - * The properties of [document] as the JSON object the platform wallet's document create - * and replace take. Bridges `dash_sdk_decision_proposal_properties_json`; the caller still - * owns [document]. - */ - external fun propertiesJson(document: Long): String? - - /** - * The stored proposal [proposalId] as a document handle (free with [documentFree]), or 0 - * when there is none. Bridges `dash_sdk_decision_proposal_fetch_document`. - */ - external fun fetchDocument(sdk: Long, proposalId: String): Long + signerHandle: Long, + outProposalId: ByteArray, + ): String /** - * A copy of [proposal] with new text and its next revision, as a new document handle - * (free with [documentFree]). No network. Bridges `dash_sdk_decision_proposal_build_edit`. + * Give the proposal [proposalId] of [ownerId] new text and broadcast the replace, signed + * via [signerHandle]. Bridges `platform_wallet_decision_proposal_edit`: the wallet reads + * the proposal once, edits it at the revision after the one read and picks the signing + * key as [create] does. * + * @param ownerId the proposer's 32-byte identity id. + * @param proposalId the proposal's 32-byte id. * @param title null keeps it. * @param description null keeps it. * @param url null keeps it, unless [removeUrl]. - * @param removeUrl removes the url; rs-sdk-ffi refuses it together with a non-null [url]. + * @param removeUrl removes the url; the wallet refuses it together with a non-null [url]. + * @return the confirmed document's canonical JSON, at its new revision. */ - external fun buildEdit( - proposal: Long, - dataContractId: String, - documentTypeName: String, + external fun edit( + walletHandle: Long, + ownerId: ByteArray, + proposalId: ByteArray, title: String?, description: String?, url: String?, removeUrl: Boolean, - ): Long - - /** Free a document handle from [build], [fetchDocument] or [buildEdit]. Safe on 0. */ - external fun documentFree(document: Long) + signerHandle: Long, + ): String /** A proposal as JSON, or null when there is none. Bridges `dash_sdk_decision_proposal_fetch`. */ external fun fetch(sdk: Long, proposalId: String): String? diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt index 400d0ad2f41..ffd5c2643ae 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModels.kt @@ -88,7 +88,7 @@ data class DecisionProposal( /** * What a new decision proposal asks and the rule it is decided by, the input of * [DecisionProposalTransactions.create]. Kotlin mirror of the C - * `DashSDKDecisionProposalParams` and Swift's `DecisionProposalParams`. Nothing is checked + * `DecisionProposalParamsFFI` and Swift's `DecisionProposalParams`. Nothing is checked * here: Platform refuses a title of more than 128 characters, a description of more than * 4096, a [passPercent] outside 50 to 80, a [minimumTurnoutPercent] above 50 and an * [endDateMs] less than a week or more than four weeks after the block that creates the @@ -136,6 +136,34 @@ data class DecisionProposalEdit( } } +/** + * How `DecisionProposalEditFFI` says a [DecisionProposalEdit.UrlChange]: the new [url], or + * null with [removeUrl] set to remove it, or null without it to keep it. + */ +internal data class NativeUrlChange(val url: String?, val removeUrl: Boolean) + +/** This change as the `url` and `remove_url` of `DecisionProposalEditFFI`. */ +internal fun DecisionProposalEdit.UrlChange.toNative(): NativeUrlChange = when (this) { + DecisionProposalEdit.UrlChange.Keep -> NativeUrlChange(url = null, removeUrl = false) + is DecisionProposalEdit.UrlChange.Set -> NativeUrlChange(url = url, removeUrl = false) + DecisionProposalEdit.UrlChange.Remove -> NativeUrlChange(url = null, removeUrl = true) +} + +/** + * A decision proposal Platform confirmed creating, as [DecisionProposalTransactions.create] + * returns it: the Kotlin shape of the `(Identifier, String)` Swift's + * `ManagedPlatformWallet.createDecisionProposal` returns. + */ +data class CreatedDecisionProposal( + /** The proposal's id, base58: the id the reads of `Sdk.decisionProposals` take. */ + val id: String, + /** + * The confirmed document's canonical JSON, the shape the platform wallet's document + * create returns. + */ + val documentJson: String, +) + /** * A masternode's answer on a decision proposal. [discriminant] is the `vote_choice` byte of * `dash_sdk_decision_proposal_cast_vote` (`0` yes, `1` no, `2` abstain); the header declares diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt index 6998d33814a..30781fbc4d5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposals.kt @@ -1,7 +1,6 @@ package org.dashfoundation.dashsdk.voting import org.dashfoundation.dashsdk.Sdk -import org.dashfoundation.dashsdk.documents.DocumentTransactions import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DecisionProposalsNative @@ -30,10 +29,6 @@ class DecisionProposals internal constructor(private val sdk: Sdk) { /** The document type name of a proposal. */ val documentTypeName: String by lazy { DecisionProposalsNative.documentTypeName() } - /** The contract id's 32 bytes, as the platform wallet's document calls take it. */ - internal val contractIdBytes: ByteArray - get() = DecisionProposalsNative.contractIdBytes() - /** The proposal [proposalId], or null when there is none. */ suspend fun fetch(proposalId: String): DecisionProposal? = sdk.queryGate.op { mapNativeErrors { DecisionProposalsNative.fetch(sdk.handle, proposalId) } @@ -150,76 +145,6 @@ class DecisionProposals internal constructor(private val sdk: Sdk) { ) } } - - /** - * The properties a new proposal of [ownerId] is created with: builds the proposal - * document (`build`), reads its properties (`propertiesJson`) and frees the document. - * The handle never leaves this call. - */ - internal suspend fun newProposalProperties( - ownerId: ByteArray, - params: DecisionProposalParams, - ): String = sdk.queryGate.op { - val built = mapNativeErrors { - DecisionProposalsNative.build( - sdk.handle, - ownerId.toBase58String(), - params.title, - params.description, - params.url, - params.passPercent, - params.minimumTurnoutPercent, - params.endDateMs, - ) - } - try { - mapNativeErrors { DecisionProposalsNative.propertiesJson(built.documentHandle) } - .returned("propertiesJson") - } finally { - DecisionProposalsNative.documentFree(built.documentHandle) - } - } - - /** - * The properties of [proposalId] with the text [edit] sets: fetches the stored proposal - * (`fetchDocument`), edits a copy (`buildEdit`), reads the copy's properties - * (`propertiesJson`) and frees both documents. The handles never leave this call. - * - * @throws DashSdkError.NotFound when there is no such proposal. - */ - internal suspend fun editedProposalProperties( - proposalId: String, - edit: DecisionProposalEdit, - ): String = sdk.queryGate.op { - val stored = mapNativeErrors { DecisionProposalsNative.fetchDocument(sdk.handle, proposalId) } - if (stored == 0L) throw DashSdkError.NotFound("There is no decision proposal $proposalId") - try { - val (url, removeUrl) = when (val change = edit.url) { - DecisionProposalEdit.UrlChange.Keep -> null to false - is DecisionProposalEdit.UrlChange.Set -> change.url to false - DecisionProposalEdit.UrlChange.Remove -> null to true - } - val edited = mapNativeErrors { - DecisionProposalsNative.buildEdit( - stored, - contractId, - documentTypeName, - edit.title, - edit.description, - url, - removeUrl, - ) - } - try { - mapNativeErrors { DecisionProposalsNative.propertiesJson(edited) } - .returned("propertiesJson") - } finally { - DecisionProposalsNative.documentFree(edited) - } - } finally { - DecisionProposalsNative.documentFree(stored) - } - } } /** @@ -227,79 +152,87 @@ class DecisionProposals internal constructor(private val sdk: Sdk) { * counterpart of Swift's `ManagedPlatformWallet` decision proposal slice * (`ManagedPlatformWalletDecisionProposals.swift`). Reach it through * `PlatformWalletManager.decisionProposals`; callers thread the wallet handle and the - * manager's `signerHandle` into each call, as for [DocumentTransactions]. + * manager's `signerHandle` into each call, as for the wallet's document calls. * - * Each call sequences native calls, never one stitched JNI export: the SDK builds the - * proposal document (or fetches and edits the stored one) and hands over its properties, - * then the wallet puts them with [DocumentTransactions.create] or - * [DocumentTransactions.replace], under the decision proposals contract and type the FFI - * names. The create transition states the decision proposal fund (0.5 Dash at protocol - * version 15) itself, paid by the owner on top of the fees. + * Each call is one native call (`platform_wallet_decision_proposal_create` / `_edit`): the + * wallet builds the proposal (or reads the stored one and edits it), picks the signing key, + * broadcasts and waits for Platform to confirm. Nothing is decided or checked here. */ -class DecisionProposalTransactions internal constructor( - private val gate: TeardownGate?, - private val documents: DocumentTransactions, -) { +class DecisionProposalTransactions internal constructor(private val gate: TeardownGate?) { /** * Create and broadcast a decision proposal of [ownerId] asking what [params] says, - * signed via [signerHandle] with a key the wallet selects. + * signed via [signerHandle] with a key the wallet selects. The create states the + * decision proposal fund (0.5 Dash at protocol version 15), paid by the owner on top of + * the fees. * - * @param sdk the SDK of the wallet's network, which builds the proposal. - * @param ownerId the proposer's 32-byte identity id; a wallet identity. - * @return the confirmed document's canonical JSON; its base58 `$id` is the proposal id. + * @param walletHandle the handle of the wallet that holds [ownerId]. + * @param ownerId the proposer's 32-byte identity id. + * @return the proposal Platform confirmed: its id and the document's canonical JSON. */ suspend fun create( - sdk: Sdk, walletHandle: Long, ownerId: ByteArray, params: DecisionProposalParams, signerHandle: Long, - ): String = gate.op { - val proposals = sdk.decisionProposals - val properties = proposals.newProposalProperties(ownerId, params) - documents.create( - walletHandle = walletHandle, - ownerId = ownerId, - contractId = proposals.contractIdBytes, - documentType = proposals.documentTypeName, - propertiesJson = properties, - signerHandle = signerHandle, - // A proposal joins no contest. - maxContestFund = 0L, - ) + ): CreatedDecisionProposal = gate.op { + val proposalId = ByteArray(PROPOSAL_ID_SIZE) + val documentJson = mapNativeErrors { + DecisionProposalsNative.create( + walletHandle, + ownerId, + params.title, + params.description, + params.url, + params.passPercent, + params.minimumTurnoutPercent, + params.endDateMs, + signerHandle, + proposalId, + ) + } + CreatedDecisionProposal(id = proposalId.toBase58String(), documentJson = documentJson) } /** * Give the proposal [proposalId] of [ownerId] the text [edit] sets and broadcast the - * replace, signed via [signerHandle] with key [signingKeyId]. The wallet re-fetches the - * proposal and moves it to the same next revision the edit was built at. + * replace, signed via [signerHandle] with a key the wallet selects as [create] does. + * + * The wallet reads the proposal once and puts the edit at the revision after the one + * read: if another edit lands in between, Platform refuses this one (its + * [DashSdkError.consensusError] code is 40106) rather than overwrite it, and the caller + * reads the proposal again before offering the edit anew. rs-sdk refuses an edit that + * changes nothing, and Platform one more than a day after the proposal was created. * * @param proposalId the proposal's 32-byte id. - * @return the confirmed document's canonical JSON. - * @throws DashSdkError.NotFound when there is no such proposal. + * @return the confirmed document's canonical JSON, at its new revision. + * @throws DashSdkError.PlatformWallet.NotFound when there is no such proposal. */ suspend fun edit( - sdk: Sdk, walletHandle: Long, ownerId: ByteArray, proposalId: ByteArray, edit: DecisionProposalEdit, - signingKeyId: Int, signerHandle: Long, ): String = gate.op { - val proposals = sdk.decisionProposals - val properties = proposals.editedProposalProperties(proposalId.toBase58String(), edit) - documents.replace( - walletHandle = walletHandle, - ownerId = ownerId, - contractId = proposals.contractIdBytes, - documentType = proposals.documentTypeName, - documentId = proposalId, - propertiesJson = properties, - signingKeyId = signingKeyId, - signerHandle = signerHandle, - ) + val url = edit.url.toNative() + mapNativeErrors { + DecisionProposalsNative.edit( + walletHandle, + ownerId, + proposalId, + edit.title, + edit.description, + url.url, + url.removeUrl, + signerHandle, + ) + } + } + + private companion object { + /** The length of the id the create writes back. */ + const val PROPOSAL_ID_SIZE = 32 } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index eac0d2bceaf..5f074a73d5b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -842,15 +842,12 @@ class PlatformWalletManager( /** * Decision proposal create and edit (protocol version 15): the decision proposal - * slice of `ManagedPlatformWallet.swift`. Stateless; callers thread the SDK, the - * wallet handle and [signerHandle] into each call. Reads and the masternode vote are - * on `Sdk.decisionProposals`. + * slice of `ManagedPlatformWallet` (`ManagedPlatformWalletDecisionProposals.swift`). + * Stateless; callers thread the wallet handle and [signerHandle] into each call. Reads + * and the masternode vote are on `Sdk.decisionProposals`. */ val decisionProposals: org.dashfoundation.dashsdk.voting.DecisionProposalTransactions = - org.dashfoundation.dashsdk.voting.DecisionProposalTransactions( - teardownGate, - documentTransactions, - ) + org.dashfoundation.dashsdk.voting.DecisionProposalTransactions(teardownGate) // ── Native manager bundle ───────────────────────────────────────── diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt index 8e7872fdf90..86f13d1988d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/voting/DecisionProposalModelsTest.kt @@ -8,7 +8,8 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Decoding of what the decision proposal natives return, and the vote choice encoding. + * Decoding of what the decision proposal natives return, and the encoding of what they take + * (the vote choice, an edit's url change). * * Rust writes every value (rs-sdk-ffi's `decision_proposal/json.rs` tests pin the same * shapes); Kotlin only decodes, so these tests pin the JSON as rs-sdk-ffi writes it, key for @@ -199,6 +200,24 @@ class DecisionProposalModelsTest { assertEquals(3, YesNoAbstainVoteChoice.entries.size) } + /** The `url` and `remove_url` of `DecisionProposalEditFFI`, one shape per change. */ + @Test + fun shouldEncodeEveryUrlChangeAsTheEditTakesIt() { + assertEquals( + NativeUrlChange(url = null, removeUrl = false), + DecisionProposalEdit.UrlChange.Keep.toNative(), + ) + assertEquals( + NativeUrlChange(url = "https://forum.dash.org/t/block-size", removeUrl = false), + DecisionProposalEdit.UrlChange.Set("https://forum.dash.org/t/block-size").toNative(), + ) + assertEquals( + NativeUrlChange(url = null, removeUrl = true), + DecisionProposalEdit.UrlChange.Remove.toNative(), + ) + assertEquals(DecisionProposalEdit.UrlChange.Keep, DecisionProposalEdit().url) + } + @Test fun shouldRefuseJsonMissingARequiredField() { val error = assertThrows(DashSdkError.SerializationError::class.java) { diff --git a/packages/rs-platform-wallet-ffi/src/decision_proposal.rs b/packages/rs-platform-wallet-ffi/src/decision_proposal.rs new file mode 100644 index 00000000000..1b6784d9217 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/decision_proposal.rs @@ -0,0 +1,602 @@ +//! FFI bindings for decision proposal create and edit on `IdentityWallet` +//! (protocol version 15). +//! +//! Thin: each function reads its C arguments, calls +//! `IdentityWallet::create_decision_proposal_with_signer` or +//! `IdentityWallet::edit_decision_proposal_with_signer` on the +//! platform-wallet 8 MB worker stack, and writes the confirmed proposal +//! back. rs-sdk and the wallet decide everything else: the proposal's +//! properties, the decision proposal fund the create states, the signing +//! key, refusing an edit that changes nothing, and the revision an edit +//! is put at. + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; +use std::slice; + +use dash_sdk::platform::decision_proposals::{DecisionProposalEdit, DecisionProposalInput}; +use dpp::document::{Document, DocumentV0Getters}; +use platform_wallet::PlatformWalletError; +use rs_sdk_ffi::{SignerHandle, VTableSigner}; + +use crate::check_ptr; +use crate::dashpay_profile::decode_opt_c_str; +use crate::document::confirmed_document_to_json; +use crate::error::*; +use crate::handle::*; +use crate::runtime::block_on_worker; +use crate::types::read_identifier; +use crate::{unwrap_option_or_return, unwrap_result_or_return}; + +/// What a new decision proposal asks and the rule it is decided by +/// (`platform_wallet_decision_proposal_create`). Platform refuses values +/// outside the limits noted here when the proposal is created; nothing is +/// checked before. +#[repr(C)] +pub struct DecisionProposalParamsFFI { + /// The question, in one line: 1 to 128 characters. NUL-terminated + /// UTF-8, required. + pub title: *const c_char, + /// What a yes and a no mean: 1 to 4096 characters. NUL-terminated + /// UTF-8, required. + pub description: *const c_char, + /// Where the proposal is discussed, a URI of at most 512 characters; + /// null for none. + pub url: *const c_char, + /// The proposal passes when the yes voting power is strictly more than + /// this percent of the yes plus no voting power: 50 to 80. + pub pass_percent: u8, + /// The least yes plus no voting power the proposal needs, as a percent + /// of the masternode list's total voting power when it closes, rounded + /// up: 0 to 50. + pub minimum_turnout_percent: u8, + /// When voting closes, in milliseconds since the Unix epoch: from a + /// week to four weeks after the block that creates the proposal, which + /// is a little later than now. + pub end_date_ms: u64, +} + +/// New text for a decision proposal +/// (`platform_wallet_decision_proposal_edit`). A field left null keeps +/// its value. Platform accepts the edit for a day after the proposal was +/// created (or a fifth of the time it runs, if that is shorter); the rule +/// and the end date never change. +#[repr(C)] +pub struct DecisionProposalEditFFI { + /// The new title, NUL-terminated UTF-8; null keeps it. + pub title: *const c_char, + /// The new description, NUL-terminated UTF-8; null keeps it. + pub description: *const c_char, + /// The new url, NUL-terminated UTF-8; null keeps it, unless + /// `remove_url` is set. + pub url: *const c_char, + /// Removes the url. Setting it together with a non-null `url` is + /// refused with `ErrorInvalidParameter`. + pub remove_url: bool, +} + +/// The UTF-8 string `field` points to; a null pointer is refused. +/// +/// # Safety +/// `ptr` must be null or point to a NUL-terminated string that outlives +/// the call. +unsafe fn required_string( + ptr: *const c_char, + field: &str, +) -> Result { + if ptr.is_null() { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + format!("{field} is null"), + )); + } + Ok(CStr::from_ptr(ptr).to_str()?.to_string()) +} + +/// The proposal input `params` holds. +/// +/// # Safety +/// `params` must point to a `DecisionProposalParamsFFI` whose string +/// fields are null or NUL-terminated. +unsafe fn proposal_input( + params: &DecisionProposalParamsFFI, +) -> Result { + Ok(DecisionProposalInput { + title: required_string(params.title, "title")?, + description: required_string(params.description, "description")?, + url: decode_opt_c_str(params.url)?, + pass_percent: params.pass_percent, + minimum_turnout_percent: params.minimum_turnout_percent, + end_date: params.end_date_ms, + }) +} + +/// The edit `edit` holds. +/// +/// # Safety +/// `edit` must point to a `DecisionProposalEditFFI` whose string fields +/// are null or NUL-terminated. +unsafe fn proposal_edit( + edit: &DecisionProposalEditFFI, +) -> Result { + let url = match (decode_opt_c_str(edit.url)?, edit.remove_url) { + (Some(_), true) => { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "url and remove_url cannot both be set", + )) + } + (Some(url), false) => Some(Some(url)), + (None, true) => Some(None), + (None, false) => None, + }; + Ok(DecisionProposalEdit { + title: decode_opt_c_str(edit.title)?, + description: decode_opt_c_str(edit.description)?, + url, + }) +} + +/// Write `json` to `*out_document_json`, handing its ownership to the +/// caller. +/// +/// # Safety +/// `out_document_json` must be a valid, writable pointer. +unsafe fn write_document_json( + json: String, + out_document_json: *mut *mut c_char, +) -> Result<(), PlatformWalletFFIResult> { + // A NUL byte inside the JSON would be a serializer bug; refuse it + // rather than panic across the FFI boundary. + *out_document_json = CString::new(json)?.into_raw(); + Ok(()) +} + +/// Create + broadcast a decision proposal of `owner_identity_id` asking +/// what `params` says, signed via the external `signer_handle`. +/// +/// Goes through `IdentityWallet::create_decision_proposal_with_signer`: +/// rs-sdk builds the proposal, whose create states the decision proposal +/// fund (0.5 Dash), paid by the owner on top of the fees; the wallet signs +/// with the owner's first AUTHENTICATION + ECDSA key at a security level +/// the `proposal` type accepts that the signer holds, broadcasts on the +/// platform-wallet 8 MB worker stack, waits for the confirmed document and +/// keeps the owner's proven balance. Platform refuses a rule outside the +/// contract's limits and an end date less than a week or more than four +/// weeks after the block that creates the proposal. A network below +/// protocol version 15 has no decision proposals, which is an error. +/// +/// On success the proposal's 32-byte id is written to `out_proposal_id`, +/// and the confirmed document's canonical JSON (the shape +/// [`platform_wallet_create_document_with_signer`](crate::platform_wallet_create_document_with_signer) +/// returns) to `*out_document_json`; release it with +/// `platform_wallet_string_free`. On any error `*out_document_json` is +/// left null. A refusal by Platform carries its `consensus_code`. +/// +/// # Safety +/// - `owner_identity_id` must point to 32 readable bytes. +/// - `params` must point to a `DecisionProposalParamsFFI` whose `title` +/// and `description` are NUL-terminated strings and whose `url` is null +/// or one. +/// - `signer_handle` must be a live signer, which the caller keeps owning. +/// - `out_proposal_id` must point to 32 writable bytes and +/// `out_document_json` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_decision_proposal_create( + wallet_handle: Handle, + owner_identity_id: *const u8, + params: *const DecisionProposalParamsFFI, + signer_handle: *mut SignerHandle, + out_proposal_id: *mut u8, + out_document_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(params); + check_ptr!(signer_handle); + check_ptr!(out_proposal_id); + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let input = unwrap_result_or_return!(proposal_input(&*params)); + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .create_decision_proposal_with_signer(&owner_id, &input, signer) + .await?; + let json = confirmed_document_to_json(&confirmed)?; + Ok::<_, PlatformWalletError>((confirmed.id(), json)) + }) + }); + let result = unwrap_option_or_return!(option); + let (proposal_id, document_json) = unwrap_result_or_return!(result); + + unwrap_result_or_return!(write_document_json(document_json, out_document_json)); + slice::from_raw_parts_mut(out_proposal_id, 32).copy_from_slice(proposal_id.as_slice()); + PlatformWalletFFIResult::ok() +} + +/// Give the decision proposal `proposal_id` of `owner_identity_id` the new +/// text `edit` sets, and broadcast the replace, signed via the external +/// `signer_handle`. +/// +/// Goes through `IdentityWallet::edit_decision_proposal_with_signer`, +/// which reads the proposal once, gives it the new text and the revision +/// after the one read (rs-sdk refuses an edit that changes nothing), signs +/// with a key chosen as the create chooses it and puts that document. It +/// never reads the proposal again before the put: if another edit lands in +/// between, Platform refuses this one (`consensus_code` 40106, +/// `InvalidDocumentRevisionError`) rather than overwrite it, and the host +/// reads the proposal again before offering the edit anew. Platform also +/// refuses an edit more than a day after the proposal was created (or a +/// fifth of the time it runs, if that is shorter). +/// +/// On success the confirmed document's canonical JSON, at its new +/// revision, is written to `*out_document_json`; release it with +/// `platform_wallet_string_free`. On any error it is left null. +/// +/// # Errors +/// - `NotFound` when there is no such proposal; +/// - `ErrorInvalidParameter` when the proposal belongs to another +/// identity, the edit changes nothing, or `url` and `remove_url` are +/// both set; +/// - `ErrorUtf8Conversion` for a string field that is not UTF-8; +/// - a refusal by Platform carries its `consensus_code`. +/// +/// # Safety +/// - `owner_identity_id` and `proposal_id` must each point to 32 readable +/// bytes. +/// - `edit` must point to a `DecisionProposalEditFFI` whose string fields +/// are null or NUL-terminated. +/// - `signer_handle` must be a live signer, which the caller keeps owning. +/// - `out_document_json` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_decision_proposal_edit( + wallet_handle: Handle, + owner_identity_id: *const u8, + proposal_id: *const u8, + edit: *const DecisionProposalEditFFI, + signer_handle: *mut SignerHandle, + out_document_json: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(edit); + check_ptr!(signer_handle); + check_ptr!(out_document_json); + *out_document_json = ptr::null_mut(); + + let owner_id = unwrap_result_or_return!(read_identifier(owner_identity_id)); + let proposal_id = unwrap_result_or_return!(read_identifier(proposal_id)); + let edit = unwrap_result_or_return!(proposal_edit(&*edit)); + let signer_addr = signer_handle as usize; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let confirmed: Document = identity_wallet + .edit_decision_proposal_with_signer(&owner_id, &proposal_id, &edit, signer) + .await?; + confirmed_document_to_json(&confirmed) + }) + }); + let result = unwrap_option_or_return!(option); + let document_json = unwrap_result_or_return!(result); + + unwrap_result_or_return!(write_document_json(document_json, out_document_json)); + PlatformWalletFFIResult::ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::state::document::invalid_document_revision_error::InvalidDocumentRevisionError; + use dpp::consensus::ConsensusError; + use dpp::prelude::Identifier; + + fn c_string(value: &str) -> CString { + CString::new(value).expect("no NUL") + } + + fn no_edit() -> DecisionProposalEditFFI { + DecisionProposalEditFFI { + title: ptr::null(), + description: ptr::null(), + url: ptr::null(), + remove_url: false, + } + } + + #[test] + fn should_read_the_proposal_params() { + let title = c_string("Raise the block size?"); + let description = c_string("Yes doubles it; no keeps it."); + let url = c_string("https://forum.dash.org/t/block-size"); + let mut params = DecisionProposalParamsFFI { + title: title.as_ptr(), + description: description.as_ptr(), + url: url.as_ptr(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: 1_701_209_600_000, + }; + unsafe { + assert_eq!( + proposal_input(¶ms).expect("reads"), + DecisionProposalInput { + title: "Raise the block size?".to_string(), + description: "Yes doubles it; no keeps it.".to_string(), + url: Some("https://forum.dash.org/t/block-size".to_string()), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date: 1_701_209_600_000, + } + ); + params.url = ptr::null(); + assert_eq!(proposal_input(¶ms).expect("reads").url, None); + params.title = ptr::null(); + let refused = proposal_input(¶ms).expect_err("no title"); + assert_eq!(refused.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + } + + #[test] + fn should_read_the_edit_and_refuse_a_url_also_removed() { + let title = c_string("Raise the block size to 4 MB?"); + let url = c_string("https://example.com"); + unsafe { + assert_eq!( + proposal_edit(&no_edit()).expect("reads"), + DecisionProposalEdit::default() + ); + assert_eq!( + proposal_edit(&DecisionProposalEditFFI { + title: title.as_ptr(), + url: url.as_ptr(), + ..no_edit() + }) + .expect("reads"), + DecisionProposalEdit { + title: Some("Raise the block size to 4 MB?".to_string()), + description: None, + url: Some(Some("https://example.com".to_string())), + } + ); + assert_eq!( + proposal_edit(&DecisionProposalEditFFI { + remove_url: true, + ..no_edit() + }) + .expect("reads") + .url, + Some(None) + ); + let refused = proposal_edit(&DecisionProposalEditFFI { + url: url.as_ptr(), + remove_url: true, + ..no_edit() + }) + .expect_err("contradictory"); + assert_eq!( + refused.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + } + + #[test] + fn should_refuse_a_string_that_is_not_utf8() { + let not_utf8 = CString::new(vec![0xff, 0xfe]).expect("no NUL"); + let description = c_string("Yes doubles it; no keeps it."); + unsafe { + let refused = proposal_input(&DecisionProposalParamsFFI { + title: not_utf8.as_ptr(), + description: description.as_ptr(), + url: ptr::null(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: 1_701_209_600_000, + }) + .expect_err("not UTF-8"); + assert_eq!( + refused.code, + PlatformWalletFFIResultCode::ErrorUtf8Conversion + ); + for edit in [ + DecisionProposalEditFFI { + title: not_utf8.as_ptr(), + ..no_edit() + }, + DecisionProposalEditFFI { + description: not_utf8.as_ptr(), + ..no_edit() + }, + DecisionProposalEditFFI { + url: not_utf8.as_ptr(), + ..no_edit() + }, + ] { + let refused = proposal_edit(&edit).expect_err("not UTF-8"); + assert_eq!( + refused.code, + PlatformWalletFFIResultCode::ErrorUtf8Conversion + ); + } + } + } + + /// Every pointer is checked before the wallet is looked up, and the + /// JSON out-param is left null on the way out. + #[test] + fn should_refuse_null_pointers() { + let id = [0u8; 32]; + let mut out_id = [0u8; 32]; + let mut out_json: *mut c_char = ptr::null_mut(); + let title = c_string("Raise the block size?"); + let description = c_string("Yes doubles it; no keeps it."); + let params = DecisionProposalParamsFFI { + title: title.as_ptr(), + description: description.as_ptr(), + url: ptr::null(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: 1_701_209_600_000, + }; + let edit = no_edit(); + // A dangling but non-null signer: every call below returns at a + // pointer check before the signer is read. + let signer = ptr::NonNull::::dangling().as_ptr(); + unsafe { + for result in [ + platform_wallet_decision_proposal_create( + 0, + id.as_ptr(), + ptr::null(), + signer, + out_id.as_mut_ptr(), + &mut out_json, + ), + platform_wallet_decision_proposal_create( + 0, + id.as_ptr(), + ¶ms, + ptr::null_mut(), + out_id.as_mut_ptr(), + &mut out_json, + ), + platform_wallet_decision_proposal_create( + 0, + id.as_ptr(), + ¶ms, + signer, + ptr::null_mut(), + &mut out_json, + ), + platform_wallet_decision_proposal_create( + 0, + id.as_ptr(), + ¶ms, + signer, + out_id.as_mut_ptr(), + ptr::null_mut(), + ), + platform_wallet_decision_proposal_edit( + 0, + id.as_ptr(), + id.as_ptr(), + ptr::null(), + signer, + &mut out_json, + ), + platform_wallet_decision_proposal_edit( + 0, + id.as_ptr(), + id.as_ptr(), + &edit, + ptr::null_mut(), + &mut out_json, + ), + platform_wallet_decision_proposal_edit( + 0, + id.as_ptr(), + id.as_ptr(), + &edit, + signer, + ptr::null_mut(), + ), + ] { + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + assert!(out_json.is_null()); + } + } + + /// An argument the wallet cannot read is refused before the wallet is + /// looked up, and the JSON out-param is left null. + #[test] + fn should_refuse_unreadable_arguments_before_the_wallet_is_looked_up() { + let id = [0u8; 32]; + let mut out_id = [0u8; 32]; + let mut out_json: *mut c_char = ptr::null_mut(); + let description = c_string("Yes doubles it; no keeps it."); + let no_title = DecisionProposalParamsFFI { + title: ptr::null(), + description: description.as_ptr(), + url: ptr::null(), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date_ms: 1_701_209_600_000, + }; + let url = c_string("https://example.com"); + let contradictory = DecisionProposalEditFFI { + url: url.as_ptr(), + remove_url: true, + ..no_edit() + }; + let signer = ptr::NonNull::::dangling().as_ptr(); + unsafe { + let created = platform_wallet_decision_proposal_create( + 0, + id.as_ptr(), + &no_title, + signer, + out_id.as_mut_ptr(), + &mut out_json, + ); + assert_eq!(created.code, PlatformWalletFFIResultCode::ErrorNullPointer); + let edited = platform_wallet_decision_proposal_edit( + 0, + id.as_ptr(), + id.as_ptr(), + &contradictory, + signer, + &mut out_json, + ); + assert_eq!( + edited.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert!(out_json.is_null()); + } + } + + /// A proposal the edit names and does not find is the "does not + /// exist" case. + #[test] + fn should_report_a_missing_proposal_as_not_found() { + let result: PlatformWalletFFIResult = PlatformWalletError::DecisionProposalNotFound { + proposal_id: Identifier::from([4; 32]), + } + .into(); + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + } + + /// Platform refuses an edit whose proposal another edit moved on since + /// it was read; the host gets the refusal's consensus code beside the + /// result code, to read the proposal again. + #[test] + fn should_carry_the_consensus_code_of_a_stale_edit() { + let cause = ConsensusError::from(InvalidDocumentRevisionError::new( + Identifier::from([4; 32]), + Some(2), + 2, + )); + let result: PlatformWalletFFIResult = PlatformWalletError::Sdk( + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 40106, + message: cause.to_string(), + cause: Some(cause), + }), + ) + .into(); + assert_eq!(result.consensus_code, 40106); + assert_eq!( + result.consensus_kind, + PlatformWalletFFIConsensusErrorKind::State + ); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/document.rs b/packages/rs-platform-wallet-ffi/src/document.rs index db066343637..36906327234 100644 --- a/packages/rs-platform-wallet-ffi/src/document.rs +++ b/packages/rs-platform-wallet-ffi/src/document.rs @@ -151,7 +151,9 @@ pub unsafe extern "C" fn platform_wallet_create_document_with_signer( /// /// Note: `dash_sdk_document_get_info` (single-document fetch) emits a different, /// per-field shape (bytes as hex); this parity is with the list-query path only. -fn confirmed_document_to_json(document: &Document) -> Result { +pub(crate) fn confirmed_document_to_json( + document: &Document, +) -> Result { let document_value = document.to_object().map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to convert confirmed document to a value: {e}" diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index d14d197ddb2..aac3c36fb4e 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1116,6 +1116,11 @@ impl From for PlatformWalletFFIResult { // rides `NotFound` rather than spending a fifth marketplace // code hosts would handle identically. PlatformWalletError::DpnsNameNotFound { .. } => PlatformWalletFFIResultCode::NotFound, + // The same "does not exist" case: the proposal an edit names + // was read and is absent. + PlatformWalletError::DecisionProposalNotFound { .. } => { + PlatformWalletFFIResultCode::NotFound + } // The persister trio, split by the store's own retry // classification — flattened to ErrorUnknown a host could not tell // a busy database from a corrupt one. `PersisterRestore` carries diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 609f72d136a..880bb66a8e1 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -24,6 +24,7 @@ pub mod dashpay_payment; pub mod dashpay_profile; pub mod dashpay_sync; pub mod data_contract; +pub mod decision_proposal; pub mod derivation; pub mod derive_and_persist_callbacks; pub mod derive_connect_key; @@ -112,6 +113,7 @@ pub use dashpay_payment::*; pub use dashpay_profile::*; pub use dashpay_sync::*; pub use data_contract::*; +pub use decision_proposal::*; pub use derivation::*; pub use derive_and_persist_callbacks::*; pub use derive_connect_key::*; diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 85b86828e64..730ae2067a4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -643,6 +643,12 @@ pub enum PlatformWalletError { #[error("DPNS name not found: {name:?}")] DpnsNameNotFound { name: String }, + /// No decision proposal with this id exists on Platform, so there is + /// nothing to edit. Distinct from [`Self::InvalidParameter`]: the id + /// was well-formed, the read came back empty. + #[error("decision proposal {proposal_id} not found")] + DecisionProposalNotFound { proposal_id: Identifier }, + /// The DPNS domain document carries no `$price` — it is not listed /// for sale. Raised by the wallet's pre-flight check and by the /// consensus downcast of `DocumentNotForSaleError` (DPP code 40108). diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/decision_proposal.rs b/packages/rs-platform-wallet/src/wallet/identity/network/decision_proposal.rs new file mode 100644 index 00000000000..cb6b3d61bc4 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/decision_proposal.rs @@ -0,0 +1,619 @@ +//! Decision proposal create and edit on `IdentityWallet` (protocol +//! version 15). +//! +//! rs-sdk decides what a proposal is (`dash_sdk::platform::decision_proposals`): +//! the properties a new proposal is created with, the decision proposal +//! fund its create states, refusing an edit that changes nothing, and the +//! revision an edit moves to. These operations orchestrate: they resolve +//! the contract, read the proposal, select the signing key, put the +//! document and keep the owner's proven balance, through the steps the +//! generic document create in `document.rs` takes. +//! +//! An edit reads the proposal once and puts that document at the revision +//! after the one it read. Nothing reads the proposal again before the put, +//! so when another edit lands in between, the stored revision has already +//! moved past the one this edit was built from and Platform refuses the +//! replace (`InvalidDocumentRevisionError`, 40106) instead of letting it +//! overwrite text it never saw. The refusal reaches the caller as +//! [`PlatformWalletError::Sdk`], which keeps Platform's consensus error. + +use dash_sdk::platform::decision_proposals::{ + build_decision_proposal_edit, DecisionProposalDocument, DecisionProposalEdit, + DecisionProposalInput, +}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::DocumentType; +use dpp::decision_proposal::{DECISION_PROPOSALS_CONTRACT_ID, PROPOSAL_DOCUMENT_TYPE_NAME}; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::prelude::Identifier; + +use crate::error::PlatformWalletError; + +use super::*; + +impl IdentityWallet { + /// Create a decision proposal of `owner_identity_id` asking what + /// `input` says, and return it as Platform stored it. + /// + /// rs-sdk builds the proposal (`Sdk::build_decision_proposal`); its + /// create states the decision proposal fund (0.5 Dash), which the + /// owner pays on top of the fees. The transition is signed with the + /// owner's first AUTHENTICATION + ECDSA key at a security level the + /// `proposal` type accepts that `signer` can sign with, and the + /// owner's balance the proof carries is kept. Platform refuses a rule + /// outside the contract's limits and an end date less than a week or + /// more than four weeks after the block that creates the proposal. + /// The proposal's id is the id of the returned document. + /// + /// A network below protocol version 15 has no decision proposals + /// contract, which is an error. SDK and Platform failures, a refusal + /// included, arrive as [`PlatformWalletError::Sdk`]. + pub async fn create_decision_proposal_with_signer( + &self, + owner_identity_id: &Identifier, + input: &DecisionProposalInput, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let document_type = self.decision_proposal_document_type().await?; + let DecisionProposalDocument { document, entropy } = self + .sdk + .build_decision_proposal(*owner_identity_id, input) + .await?; + let signing_key = self + .select_document_signing_key(owner_identity_id, &document_type, signer) + .await?; + Ok(self + .put_document_keeping_owner_balance( + &document, + document_type, + Some(entropy.to_buffer()), + signing_key, + signer, + None, + ) + .await?) + } + + /// Give the decision proposal `proposal_id` of `owner_identity_id` + /// the new text `edit` sets, and return it as Platform stored it. + /// + /// Reads the proposal once, applies the edit with rs-sdk's + /// `build_decision_proposal_edit` (which refuses an edit that changes + /// nothing and moves to the revision after the one read) and puts + /// that document as a replace, signed and kept like + /// [`Self::create_decision_proposal_with_signer`]. The put carries the + /// revision after the one read: if another edit lands in between, + /// Platform refuses this one (`InvalidDocumentRevisionError`, 40106) + /// rather than overwrite it, and the caller reads the proposal again. + /// Platform also refuses an edit more than a day after the proposal + /// was created (or a fifth of the time it runs, if that is shorter). + /// + /// # Errors + /// * [`PlatformWalletError::DecisionProposalNotFound`] when there is + /// no such proposal; + /// * [`PlatformWalletError::InvalidParameter`] when the proposal + /// belongs to another identity, or the edit changes nothing; + /// * [`PlatformWalletError::Sdk`] for SDK and Platform failures, + /// Platform's refusals included. + pub async fn edit_decision_proposal_with_signer( + &self, + owner_identity_id: &Identifier, + proposal_id: &Identifier, + edit: &DecisionProposalEdit, + signer: &S, + ) -> Result + where + S: Signer + Send + Sync, + { + let document_type = self.decision_proposal_document_type().await?; + let read = self.sdk.fetch_decision_proposal(*proposal_id).await?; + let replacement = + decision_proposal_replacement(owner_identity_id, proposal_id, read, edit)?; + let signing_key = self + .select_document_signing_key(owner_identity_id, &document_type, signer) + .await?; + Ok(self + .put_document_keeping_owner_balance( + &replacement, + document_type, + None, + signing_key, + signer, + None, + ) + .await?) + } + + /// The `proposal` document type of the decision proposals contract, + /// which rs-sdk resolves (refusing a network below protocol version + /// 15). The contract is registered for the proof check that follows + /// the put. + async fn decision_proposal_document_type(&self) -> Result { + let contract = self.sdk.fetch_decision_proposals_contract().await?; + self.register_contract_for_proof_verification(&contract); + contract + .document_type_cloned_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .map_err(|e| PlatformWalletError::Sdk(dash_sdk::Error::Protocol(e.into()))) + } +} + +/// The document an edit of the proposal `proposal_id` puts: `read`, the +/// proposal as read once for this edit, with the text `edit` sets and the +/// revision after the one read. A proposal that is not there, or that +/// belongs to an identity other than `owner_identity_id`, is refused. +fn decision_proposal_replacement( + owner_identity_id: &Identifier, + proposal_id: &Identifier, + read: Option, + edit: &DecisionProposalEdit, +) -> Result { + let proposal = read.ok_or(PlatformWalletError::DecisionProposalNotFound { + proposal_id: *proposal_id, + })?; + if proposal.owner_id() != *owner_identity_id { + return Err(PlatformWalletError::InvalidParameter(format!( + "decision proposal {proposal_id} belongs to identity {}, not to {owner_identity_id}", + proposal.owner_id() + ))); + } + build_decision_proposal_edit( + &DECISION_PROPOSALS_CONTRACT_ID, + PROPOSAL_DOCUMENT_TYPE_NAME, + &proposal, + edit, + ) + .map_err(|error| match error { + // rs-sdk refuses the edit itself (one that changes nothing) with + // its own text; anything else keeps its SDK error. + dash_sdk::Error::Generic(message) => PlatformWalletError::InvalidParameter(message), + other => PlatformWalletError::Sdk(other), + }) +} + +#[cfg(test)] +mod tests { + use super::super::signing_key::tests::{ + available, identity, lock_checking_signer, wallet_with_signing_keys_on, + }; + use super::*; + use crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX; + use crate::wallet::persister::NoPlatformPersistence; + use async_trait::async_trait; + use dash_sdk::dapi_grpc::platform::v0::GetDocumentsRequest; + use dash_sdk::error::StateTransitionBroadcastError; + use dash_sdk::platform::decision_proposals::build_decision_proposal_document; + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + use dash_sdk::platform::{DocumentQuery, Query}; + use dash_sdk::query_types::IdentityContractNonceFetcher; + use dpp::address_funds::AddressWitness; + use dpp::consensus::state::document::invalid_document_revision_error::InvalidDocumentRevisionError; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::data_contract::DataContract; + use dpp::decision_proposal::properties; + use dpp::document::{DocumentV0Setters, INITIAL_REVISION}; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::{BinaryData, Bytes32, Value}; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + use dpp::ProtocolError; + use std::sync::Arc; + + const CREATED_AT: u64 = 1_700_000_000_000; + /// The identity contract nonce Platform reports for the owner. + const STORED_NONCE: u64 = 4; + const NEW_TITLE: &str = "Raise the block size to 4 MB?"; + + /// The owner identity `wallet_with_signing_keys_on` holds. + fn owner() -> Identifier { + Identifier::default() + } + + fn proposals_contract() -> Arc { + Arc::new( + load_system_data_contract( + SystemDataContract::DecisionProposals, + PlatformVersion::latest(), + ) + .expect("the decision proposals contract loads at the latest version"), + ) + } + + fn input() -> DecisionProposalInput { + DecisionProposalInput { + title: "Raise the block size?".to_string(), + description: "Yes doubles it; no keeps it.".to_string(), + url: Some("https://forum.dash.org/t/block-size".to_string()), + pass_percent: 60, + minimum_turnout_percent: 10, + end_date: CREATED_AT + 14 * 86_400_000, + } + } + + /// A proposal of `owner_id` built by rs-sdk, as Platform stores it at + /// its first revision. + fn stored_proposal(owner_id: Identifier) -> Document { + let mut document = build_decision_proposal_document( + &proposals_contract(), + owner_id, + &input(), + Bytes32::new([9; 32]), + ) + .expect("builds") + .document; + document.set_created_at(Some(CREATED_AT)); + document.set_updated_at(Some(CREATED_AT)); + document + } + + fn retitle() -> DecisionProposalEdit { + DecisionProposalEdit { + title: Some(NEW_TITLE.to_string()), + ..Default::default() + } + } + + /// Serves the bundled decision proposals contract (nothing else), the + /// way a host's context provider does, so rs-sdk resolves it without a + /// network fetch or a dump directory. + struct ProposalsContractProvider(Arc); + + impl dash_sdk::platform::ContextProvider for ProposalsContractProvider { + fn get_data_contract( + &self, + id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, dash_sdk::error::ContextProviderError> { + Ok((*id == self.0.id()).then(|| Arc::clone(&self.0))) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result< + Option, + dash_sdk::error::ContextProviderError, + > { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], dash_sdk::error::ContextProviderError> { + Err(dash_sdk::error::ContextProviderError::Config( + "no quorum keys in this test".to_string(), + )) + } + + fn get_platform_activation_height( + &self, + ) -> Result { + Ok(1) + } + } + + /// The query rs-sdk reads the proposal `proposal_id` with. + fn proposal_query(proposal_id: Identifier) -> DocumentQuery { + DocumentQuery::new(proposals_contract(), PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("the proposal type") + .with_document_id(&proposal_id) + } + + /// A mock SDK at the latest protocol version that resolves the + /// decision proposals contract. A request without an expectation + /// fails. + fn proposals_sdk() -> dash_sdk::Sdk { + dash_sdk::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .with_context_provider(ProposalsContractProvider(proposals_contract())) + .build() + .expect("mock sdk") + } + + /// [`proposals_sdk`] answering a read of the proposal `proposal_id` + /// with `stored`. + async fn sdk_storing(proposal_id: Identifier, stored: Option) -> dash_sdk::Sdk { + let mut sdk = proposals_sdk(); + sdk.mock() + .expect_fetch(proposal_query(proposal_id), stored) + .await + .expect("expects the proposal read"); + sdk + } + + /// Times the proposal `proposal_id` was read through `sdk`. + async fn times_read(sdk: &mut dash_sdk::Sdk, proposal_id: Identifier) -> usize { + let request: GetDocumentsRequest = Query::::query( + &proposal_query(proposal_id), + &sdk.query_settings(), + ) + .expect("encodes"); + sdk.mock().request_count(&request).await + } + + /// Signs with a fixed signature, so the transition it signs is known. + #[derive(Debug)] + struct FixedSignatureSigner; + + #[async_trait] + impl Signer for FixedSignatureSigner { + async fn sign(&self, _: &IdentityPublicKey, _: &[u8]) -> Result { + Ok(BinaryData::new(vec![7; 65])) + } + + async fn sign_create_witness( + &self, + _: &IdentityPublicKey, + _: &[u8], + ) -> Result { + Err(ProtocolError::Generic( + "a document transition has no witness".to_string(), + )) + } + + fn can_sign_with(&self, _: &IdentityPublicKey) -> bool { + true + } + } + + #[test] + fn should_move_the_proposal_read_to_its_next_revision_with_only_the_edited_text() { + let read = stored_proposal(owner()); + let replacement = + decision_proposal_replacement(&owner(), &read.id(), Some(read.clone()), &retitle()) + .expect("edits"); + + assert_eq!(replacement.id(), read.id()); + assert_eq!(replacement.owner_id(), owner()); + assert_eq!(replacement.revision(), Some(INITIAL_REVISION + 1)); + assert_eq!(replacement.created_at(), Some(CREATED_AT)); + let mut expected = read.properties().clone(); + expected.insert( + properties::TITLE.to_string(), + Value::Text(NEW_TITLE.to_string()), + ); + assert_eq!(replacement.properties(), &expected); + } + + /// Another client's edit lands between this edit's read and its put. + /// The put carries the revision after the one read and the text as + /// read plus this edit; it is not rebuilt on top of the newer + /// revision. Platform takes a replace of the proposal it now stores + /// only at the revision after that one, so it refuses this put + /// (`InvalidDocumentRevisionError`) instead of publishing the + /// description the other edit replaced. + #[test] + fn should_keep_the_revision_read_when_another_edit_lands_before_the_put() { + let read = stored_proposal(owner()); + let replacement = + decision_proposal_replacement(&owner(), &read.id(), Some(read.clone()), &retitle()) + .expect("edits"); + + let concurrent = build_decision_proposal_edit( + &DECISION_PROPOSALS_CONTRACT_ID, + PROPOSAL_DOCUMENT_TYPE_NAME, + &read, + &DecisionProposalEdit { + description: Some("Yes doubles it.".to_string()), + ..Default::default() + }, + ) + .expect("the other client's edit"); + assert_eq!(concurrent.revision(), Some(INITIAL_REVISION + 1)); + + assert_eq!(replacement.revision(), Some(INITIAL_REVISION + 1)); + let accepted_after_concurrent = concurrent.revision().map(|revision| revision + 1); + assert_ne!(replacement.revision(), accepted_after_concurrent); + assert_eq!( + replacement.get(properties::DESCRIPTION), + read.get(properties::DESCRIPTION) + ); + } + + #[test] + fn should_refuse_an_edit_that_changes_nothing() { + let read = stored_proposal(owner()); + let error = decision_proposal_replacement( + &owner(), + &read.id(), + Some(read.clone()), + &DecisionProposalEdit { + title: Some(input().title), + ..Default::default() + }, + ) + .expect_err("changes nothing"); + assert!( + matches!(error, PlatformWalletError::InvalidParameter(ref message) + if message.contains("changes nothing")), + "{error:?}" + ); + } + + /// The edit reads the proposal once, then puts exactly that document + /// at the revision after the one read: the broadcast the put sends is + /// the replace built from it, signed with the owner's first eligible + /// key at the next identity contract nonce. The mock answers no + /// broadcast, so the put fails after sending it. + #[tokio::test] + async fn should_put_the_proposal_read_once_at_the_revision_after_it() { + let read = stored_proposal(owner()); + let proposal_id = read.id(); + let mut sdk = sdk_storing(proposal_id, Some(read.clone())).await; + sdk.mock() + .expect_fetch( + (owner(), DECISION_PROPOSALS_CONTRACT_ID), + Some(IdentityContractNonceFetcher(STORED_NONCE)), + ) + .await + .expect("expects the nonce read"); + let wallet = + wallet_with_signing_keys_on(sdk.clone(), Arc::new(NoPlatformPersistence)).await; + + let error = wallet + .identity() + .edit_decision_proposal_with_signer( + &owner(), + &proposal_id, + &retitle(), + &FixedSignatureSigner, + ) + .await + .expect_err("the mock answers no broadcast"); + assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); + assert_eq!(times_read(&mut sdk, proposal_id).await, 1); + + // The proposal as the read returned it: the mock answers with the + // document re-encoded, so its integer values come back as Platform + // returns them rather than as the builder made them. + let returned = sdk + .fetch_decision_proposal(proposal_id) + .await + .expect("reads") + .expect("the stored proposal"); + let contract = proposals_contract(); + let document_type = contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("the proposal type"); + let mut put_document = returned; + put_document.set(properties::TITLE, Value::Text(NEW_TITLE.to_string())); + put_document.set_revision(Some(INITIAL_REVISION + 1)); + let signing_key = identity( + Purpose::AUTHENTICATION, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + ) + .public_keys() + .get(&1) + .expect("the owner's first key") + .clone(); + let replace = BatchTransition::new_document_replacement_transition_from_document( + put_document, + document_type, + &signing_key, + STORED_NONCE + 1, + 0, + None, + &FixedSignatureSigner, + PlatformVersion::latest(), + None, + ) + .await + .expect("the replace"); + let broadcast = replace + .broadcast_request_for_state_transition() + .expect("serializes"); + assert!( + sdk.mock().request_count(&broadcast).await > 0, + "the put must broadcast the replace of the proposal as read, at revision {}", + INITIAL_REVISION + 1 + ); + } + + #[tokio::test] + async fn should_refuse_to_edit_a_proposal_that_does_not_exist() { + let proposal_id = Identifier::from([4; 32]); + let mut sdk = sdk_storing(proposal_id, None).await; + let wallet = + wallet_with_signing_keys_on(sdk.clone(), Arc::new(NoPlatformPersistence)).await; + + let error = wallet + .identity() + .edit_decision_proposal_with_signer(&owner(), &proposal_id, &retitle(), &available(&[])) + .await + .expect_err("no such proposal"); + assert!( + matches!(error, PlatformWalletError::DecisionProposalNotFound { proposal_id: id } + if id == proposal_id), + "{error:?}" + ); + assert_eq!(times_read(&mut sdk, proposal_id).await, 1); + } + + #[tokio::test] + async fn should_refuse_to_edit_a_proposal_of_another_identity() { + let read = stored_proposal(Identifier::from([3; 32])); + let proposal_id = read.id(); + let sdk = sdk_storing(proposal_id, Some(read)).await; + let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; + + let error = wallet + .identity() + .edit_decision_proposal_with_signer(&owner(), &proposal_id, &retitle(), &available(&[])) + .await + .expect_err("another identity's proposal"); + assert!( + matches!(error, PlatformWalletError::InvalidParameter(ref message) + if message.contains("belongs to identity")), + "{error:?}" + ); + } + + /// The create builds the proposal through rs-sdk, then asks the signer + /// for a key with the wallet manager lock released: a signer that holds + /// no key keeps the structured key-unavailable failure, and an owner + /// the wallet does not hold is refused. + #[tokio::test] + async fn should_create_only_for_an_owner_with_a_key_the_signer_holds() { + let wallet = + wallet_with_signing_keys_on(proposals_sdk(), Arc::new(NoPlatformPersistence)).await; + let signer = lock_checking_signer(wallet.identity()); + + let error = wallet + .identity() + .create_decision_proposal_with_signer(&owner(), &input(), &signer) + .await + .expect_err("no key available"); + assert!( + matches!(error, PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::Generic(ref message))) + if message.starts_with(SIGNER_KEY_UNAVAILABLE_PREFIX)), + "{error:?}" + ); + + let stranger = Identifier::from([5; 32]); + let error = wallet + .identity() + .create_decision_proposal_with_signer(&stranger, &input(), &available(&[1])) + .await + .expect_err("not an identity of this wallet"); + assert!( + matches!(error, PlatformWalletError::IdentityNotFound(id) if id == stranger), + "{error:?}" + ); + } + + /// Platform's refusal of a stale replace keeps its consensus error, so + /// the FFI hands the host its code (40106) to read the proposal again. + #[test] + fn should_keep_platforms_refusal_of_a_stale_revision() { + let cause = ConsensusError::StateError(StateError::InvalidDocumentRevisionError( + InvalidDocumentRevisionError::new(Identifier::from([4; 32]), Some(2), 2), + )); + let refusal = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 40106, + message: cause.to_string(), + cause: Some(cause), + }); + let error = PlatformWalletError::from(refusal); + assert!( + matches!( + error.consensus_error(), + Some(ConsensusError::StateError(StateError::InvalidDocumentRevisionError(refused))) + if refused.previous_revision() == &Some(2) && refused.desired_revision() == &2 + ), + "{error:?}" + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 0ddd1586b80..1307394d874 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -37,10 +37,11 @@ use dpp::address_funds::AddressWitness; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::DocumentType; use dpp::document::document_methods::DocumentMethodsV0; use dpp::document::property_names::CREATED_AT; use dpp::document::Document; -use dpp::document::DocumentV0Setters; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; use dpp::fee::Credits; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -274,38 +275,9 @@ impl IdentityWallet { // AUTHENTICATION + ECDSA key whose security level satisfies // the document type's requirement (NOT a fixed CRITICAL like // the contract-create path). - let required_level = document_type.security_level_requirement(); - let allowed_levels = allowed_signing_security_levels(required_level); - let signing_key = { - let wm = self.wallet_manager.read().await; - let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { - PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - let manager = &info.identity_manager; - let identity = manager - .identity(owner_identity_id) - .map(|m| m.identity.clone()) - .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))?; - drop(wm); - identity - .available_signing_key( - signer, - Purpose::AUTHENTICATION, - &allowed_levels, - &[KeyType::ECDSA_SECP256K1], - false, - )? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "No ECDSA authentication key at a security level satisfying \ - {required_level} found on owner identity {owner_identity_id} \ - (required to sign a {document_type_name} document state transition)" - )) - })? - .clone() - }; + let signing_key = self + .select_document_signing_key(owner_identity_id, &document_type, signer) + .await?; // 4. Broadcast via `PutDocument` on the platform-wallet 8 MB // worker stack. `None` entropy -> the SDK generates entropy @@ -318,30 +290,113 @@ impl IdentityWallet { }), ..Default::default() }); + self.put_document_keeping_owner_balance( + &document, + document_type, + None, + signing_key, + signer, + settings, + ) + .await + .map_err(|e| { + // Preserve a structured key-unavailable signer failure so the + // FFI boundary can still restore code 31; only genuine + // operation failures get stringified into `InvalidIdentityData`. + preserve_signer_key_unavailable_or(e, |e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to put document to platform: {e}" + )) + }) + }) + } + + /// The key a `document_type` state transition of `owner_identity_id` + /// is signed with when the caller names none: the first + /// AUTHENTICATION + ECDSA_SECP256K1 key, by key id, of the owner + /// identity in the wallet manager whose security level satisfies the + /// type's requirement (see [`allowed_signing_security_levels`]) and + /// that `signer` can sign with. The wallet manager lock is released + /// before `signer` is asked. + pub(super) async fn select_document_signing_key( + &self, + owner_identity_id: &Identifier, + document_type: &DocumentType, + signer: &S, + ) -> Result + where + S: Signer, + { + let required_level = document_type.security_level_requirement(); + let allowed_levels = allowed_signing_security_levels(required_level); + let identity = { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + info.identity_manager + .identity(owner_identity_id) + .map(|m| m.identity.clone()) + .ok_or(PlatformWalletError::IdentityNotFound(*owner_identity_id))? + }; + let signing_key = identity + .available_signing_key( + signer, + Purpose::AUTHENTICATION, + &allowed_levels, + &[KeyType::ECDSA_SECP256K1], + false, + )? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "No ECDSA authentication key at a security level satisfying \ + {required_level} found on owner identity {owner_identity_id} \ + (required to sign a {} document state transition)", + document_type.name() + )) + })?; + Ok(signing_key.clone()) + } + + /// Put `document` (a create at revision 1, a replace above it), + /// signed with `signing_key`, wait for the proof that it executed, + /// keep the owner's balance the proof carries (from protocol version + /// 14) and return the confirmed `Document`. + /// + /// The document goes out exactly as given: a replace carries its own + /// revision, which Platform takes only as the stored revision plus + /// one, so nothing here reads the document again or moves its + /// revision. `entropy` is read for a create only; `None` lets the SDK + /// generate it. Runs on the caller's runtime; the FFI drives it on the + /// platform-wallet 8 MB worker stack for the post-broadcast GroveDB + /// proof-verification recursion. + pub(super) async fn put_document_keeping_owner_balance( + &self, + document: &Document, + document_type: DocumentType, + entropy: Option<[u8; 32]>, + signing_key: IdentityPublicKey, + signer: &S, + settings: Option, + ) -> Result + where + S: Signer + Send + Sync, + { let (confirmed, owner_balance, metadata) = document .put_to_platform_and_wait_for_response_with_owner_balance_and_metadata( &self.sdk, document_type, - None, + entropy, signing_key, None, &SignerRef(signer), settings, ) - .await - .map_err(|e| { - // Preserve a structured key-unavailable signer failure so the - // FFI boundary can still restore code 31; only genuine - // operation failures get stringified into `InvalidIdentityData`. - crate::error::preserve_signer_key_unavailable_or(e, |e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to put document to platform: {e}" - )) - }) - })?; - self.persist_proven_owner_balance(owner_identity_id, owner_balance, metadata) + .await?; + self.persist_proven_owner_balance(&document.owner_id(), owner_balance, metadata) .await; - Ok(confirmed) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index e8af7ec0136..2c2a6c89b43 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -34,6 +34,7 @@ pub(crate) const DPNS_USERNAMES_MAX_PAGES: usize = 10; // Core handle + identity-lifecycle operations. mod balance; mod contract; +mod decision_proposal; mod discovery; mod document; mod dpns; diff --git a/packages/rs-sdk-ffi/src/decision_proposal/json.rs b/packages/rs-sdk-ffi/src/decision_proposal/json.rs index bf48e1a5ebf..84c7f10b88e 100644 --- a/packages/rs-sdk-ffi/src/decision_proposal/json.rs +++ b/packages/rs-sdk-ffi/src/decision_proposal/json.rs @@ -67,12 +67,6 @@ pub(super) fn proposals_json(documents: Documents) -> Result Result { - Ok(serde_json::to_value(document.properties())?) -} - fn block_json(block: &BlockInfo) -> JsonValue { json!({ "height": block.height, @@ -270,23 +264,6 @@ pub(super) mod tests { ); } - /// The properties are the document's own, which the wallet's create takes back. - #[test] - fn should_write_the_properties_a_create_takes() { - let document = stored_proposal(&input()); - assert_eq!( - properties_json(&document).expect("properties"), - json!({ - "title": "Raise the block size?", - "description": "Yes doubles it; no keeps it.", - "url": "https://forum.dash.org/t/block-size", - "passPercent": 60, - "minimumTurnoutPercent": 10, - "endDate": END_DATE, - }) - ); - } - fn poll() -> YesNoVotePoll { YesNoVotePoll { purpose: YesNoVotePollPurpose::DecisionProposal { diff --git a/packages/rs-sdk-ffi/src/decision_proposal/mod.rs b/packages/rs-sdk-ffi/src/decision_proposal/mod.rs index 53cfe81925f..5f7491b5843 100644 --- a/packages/rs-sdk-ffi/src/decision_proposal/mod.rs +++ b/packages/rs-sdk-ffi/src/decision_proposal/mod.rs @@ -1,37 +1,19 @@ //! Decision proposals (protocol version 15): yes/no questions any identity puts to the //! masternodes, as `proposal` documents of the decision proposals system contract. Thin //! bindings over `dash_sdk::platform::decision_proposals` and -//! `dash_sdk::platform::yes_no_vote_polls`: rs-sdk decides everything (the proposal's -//! properties, the decision proposal fund the create states, which documents are proposals, -//! refusing an edit that changes nothing, the next revision, the poll's id); these functions -//! read C arguments, call it, and write the result. +//! `dash_sdk::platform::yes_no_vote_polls`: rs-sdk decides everything (which documents are +//! proposals, the poll's id, the vote); these functions read C arguments, call it, and write +//! the result. //! -//! # Creating a proposal +//! # Creating and editing a proposal //! -//! [`dash_sdk_decision_proposal_build`] builds the proposal document and its entropy. Then -//! either: -//! * put it with `dash_sdk_document_put_to_platform_and_wait`, passing the document handle, -//! the entropy, [`dash_sdk_decision_proposal_contract_id`] and -//! [`dash_sdk_decision_proposal_document_type_name`]; or -//! * pass [`dash_sdk_decision_proposal_properties_json`] of the document to the platform -//! wallet's `platform_wallet_create_document_with_signer`, with -//! [`dash_sdk_decision_proposal_contract_id_bytes`] and the document type name. -//! -//! Either way the create states the decision proposal fund (0.5 Dash) itself, on top of the -//! fees. The proposal's id is final once the put returns the stored document. -//! -//! # Editing a proposal's text -//! -//! [`dash_sdk_decision_proposal_fetch_document`] reads the stored proposal as a document -//! handle, and [`dash_sdk_decision_proposal_build_edit`] gives a copy of it the new text and -//! its next revision. Then either: -//! * put the edited document with `dash_sdk_document_put_to_platform_and_wait` (its revision -//! is above 1, so it is put as a replace; the entropy argument is not read for a replace, -//! pass any 32 bytes). Never `dash_sdk_document_replace_on_platform_and_wait`, which moves -//! the revision once more and is refused; or -//! * pass [`dash_sdk_decision_proposal_properties_json`] of the edited document to the -//! platform wallet's `platform_wallet_document_replace` with the proposal's id. The wallet -//! fetches the stored proposal, sets these properties and moves to the same next revision. +//! A proposal is created and edited through the platform wallet, which signs with the +//! owner's identity keys: `platform_wallet_decision_proposal_create` and +//! `platform_wallet_decision_proposal_edit` in `rs-platform-wallet-ffi`. rs-sdk builds the +//! proposal (the create states the decision proposal fund itself) and the edit (the next +//! revision, refusing one that changes nothing); the wallet reads the proposal once for an +//! edit and puts it at the revision after the one read, so Platform refuses an edit another +//! one overtook instead of letting it overwrite that one. //! //! # Reading //! diff --git a/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs b/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs index 5b95c7b458d..d91a62ee0be 100644 --- a/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs +++ b/packages/rs-sdk-ffi/src/decision_proposal/transitions.rs @@ -1,63 +1,20 @@ -//! Decision proposal writes: building a proposal to create, giving a stored proposal new -//! text, the properties the platform wallet puts, and a masternode's vote on a proposal. The -//! module docs of [`super`] show how they chain into the document put functions. +//! Decision proposal writes from rs-sdk-ffi: a masternode's vote on a proposal, and the +//! names of the decision proposals contract and of its `proposal` type. A proposal is +//! created and edited through the platform wallet (`platform_wallet_decision_proposal_create` +//! and `platform_wallet_decision_proposal_edit` in `rs-platform-wallet-ffi`). -use super::json::properties_json; -use super::{json_result, optional_str, required_identifier, required_str, sdk_wrapper}; -use crate::document::DashSDKDocumentCreateResult; -use crate::types::{DashSDKResultDataType, DocumentHandle, FFINetwork, SDKHandle}; +use super::{required_identifier, sdk_wrapper}; +use crate::types::{FFINetwork, SDKHandle}; use crate::voting::masternode_vote::MasternodeVoter; use crate::{DashSDKResult, FFIError}; use dash_sdk::dpp::decision_proposal::{ DECISION_PROPOSALS_CONTRACT_ID, PROPOSAL_DOCUMENT_TYPE_NAME, }; -use dash_sdk::dpp::document::Document; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; -use dash_sdk::platform::decision_proposals::{ - build_decision_proposal_edit, DecisionProposalDocument, DecisionProposalEdit, - DecisionProposalInput, -}; -use std::ffi::{c_char, c_void, CString}; +use std::ffi::{c_char, CString}; use std::sync::OnceLock; -/// What a new decision proposal asks and the rule it is decided by -/// (`dash_sdk_decision_proposal_build`). Platform refuses values outside the limits noted -/// here when the proposal is created; nothing is checked before. -#[repr(C)] -pub struct DashSDKDecisionProposalParams { - /// The question, in one line: 1 to 128 characters. NUL-terminated UTF-8, required. - pub title: *const c_char, - /// What a yes and a no mean: 1 to 4096 characters. NUL-terminated UTF-8, required. - pub description: *const c_char, - /// Where the proposal is discussed, a URI of at most 512 characters; null for none. - pub url: *const c_char, - /// The proposal passes when the yes voting power is strictly more than this percent of - /// the yes plus no voting power: 50 to 80. - pub pass_percent: u8, - /// The least yes plus no voting power the proposal needs, as a percent of the masternode - /// list's total voting power when it closes, rounded up: 0 to 50. - pub minimum_turnout_percent: u8, - /// When voting closes, in milliseconds since the Unix epoch: from a week to four weeks - /// after the block that creates the proposal, which is a little later than now. - pub end_date_ms: u64, -} - -/// New text for a stored decision proposal (`dash_sdk_decision_proposal_build_edit`). A -/// field left null keeps its value. Platform accepts the edit for a day after the proposal -/// was created; the rule and the end date never change. -#[repr(C)] -pub struct DashSDKDecisionProposalEdit { - /// The new title, NUL-terminated UTF-8; null keeps it. - pub title: *const c_char, - /// The new description, NUL-terminated UTF-8; null keeps it. - pub description: *const c_char, - /// The new url, NUL-terminated UTF-8; null keeps it, unless `remove_url` is set. - pub url: *const c_char, - /// Removes the url. Setting it together with a non-null `url` is refused. - pub remove_url: bool, -} - /// The answer of a masternode's vote on a decision proposal, the `vote_choice` byte of /// `dash_sdk_decision_proposal_cast_vote`, in the order of `YesNoAbstainVoteChoice`: /// `0` = yes, `1` = no, `2` = abstain. @@ -88,7 +45,7 @@ fn vote_choice_from_ffi(vote_choice: u8) -> Result *const c_char { .as_ptr() } -/// Writes the 32 bytes of the decision proposals system contract id to `out_contract_id`: -/// the `contract_id` the platform wallet's document functions take for a proposal. +/// Writes the 32 bytes of the decision proposals system contract id to `out_contract_id`, +/// for the functions that take a contract id as bytes. /// /// # Returns /// `false` when `out_contract_id` is null, `true` once the id is written. @@ -124,7 +81,7 @@ pub unsafe extern "C" fn dash_sdk_decision_proposal_contract_id_bytes( } /// The document type name of a proposal (`"proposal"`), the `document_type_name` the -/// document put and platform wallet functions take. +/// generic document functions take to read proposals. /// /// # Returns /// A static NUL-terminated string. Do not free it. @@ -138,260 +95,6 @@ pub extern "C" fn dash_sdk_decision_proposal_document_type_name() -> *const c_ch .as_ptr() } -/// Builds a decision proposal of `owner_identity_id`: a `proposal` document of the decision -/// proposals contract asking what `params` says, with a fresh entropy. Nothing is broadcast. -/// -/// Put the document with `dash_sdk_document_put_to_platform_and_wait(sdk_handle, -/// result.document_handle, dash_sdk_decision_proposal_contract_id(), -/// dash_sdk_decision_proposal_document_type_name(), &result.entropy, key, signer, ...)`, or -/// pass `dash_sdk_decision_proposal_properties_json(result.document_handle)` to the platform -/// wallet's `platform_wallet_create_document_with_signer`. The create states the decision -/// proposal fund (0.5 Dash), paid by the owner on top of the fees. Platform refuses a rule -/// outside the contract's limits and an end date less than a week or more than four weeks -/// after the block that creates the proposal. The document id is a placeholder until the put -/// returns the stored document. -/// -/// Reads the decision proposals contract from the context provider, or fetches it; a network -/// below protocol version 15 has no such contract, which is an error. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance. -/// * `owner_identity_id` - The proposer's identity id, base58. -/// * `params` - The question and its rule. -/// -/// # Returns -/// On success `data` points to a `DashSDKDocumentCreateResult` (`data_type` is `NoData`, as -/// for `dash_sdk_document_create`): the document handle and the 32-byte entropy the put -/// must reuse. Free the struct with `dash_sdk_document_create_result_free`, which leaves the -/// document handle alive, and the handle with `dash_sdk_document_handle_destroy`. -/// -/// # Safety -/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. -/// - `owner_identity_id` must be a valid pointer to a NUL-terminated string. -/// - `params` must be a valid pointer to a `DashSDKDecisionProposalParams` whose strings are -/// NUL-terminated (`url` may be null). All must stay valid for the duration of the call. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_decision_proposal_build( - sdk_handle: *const SDKHandle, - owner_identity_id: *const c_char, - params: *const DashSDKDecisionProposalParams, -) -> DashSDKResult { - match build_inner(sdk_handle, owner_identity_id, params) { - Ok(built) => create_result(built), - Err(error) => DashSDKResult::error(error.into()), - } -} - -/// `built` as `dash_sdk_document_create` returns a document to create: a -/// `DashSDKDocumentCreateResult` holding the document handle and the entropy. -fn create_result(built: DecisionProposalDocument) -> DashSDKResult { - let document_handle = Box::into_raw(Box::new(built.document)) as *mut DocumentHandle; - let create_result = Box::new(DashSDKDocumentCreateResult { - document_handle, - entropy: built.entropy.0, - }); - DashSDKResult::success(Box::into_raw(create_result) as *mut c_void) -} - -unsafe fn build_inner( - sdk_handle: *const SDKHandle, - owner_identity_id: *const c_char, - params: *const DashSDKDecisionProposalParams, -) -> Result { - let wrapper = sdk_wrapper(sdk_handle)?; - let owner_id = required_identifier(owner_identity_id, "owner_identity_id")?; - let input = proposal_input(params)?; - Ok(wrapper - .runtime - .block_on(wrapper.sdk.build_decision_proposal(owner_id, &input))?) -} - -/// The proposal input `params` holds. -/// -/// # Safety -/// `params` must be null or point to a `DashSDKDecisionProposalParams` whose string fields -/// are null or NUL-terminated. -unsafe fn proposal_input( - params: *const DashSDKDecisionProposalParams, -) -> Result { - if params.is_null() { - return Err(FFIError::InvalidParameter("params is null".to_string())); - } - let params = &*params; - Ok(DecisionProposalInput { - title: required_str(params.title, "title")?.to_string(), - description: required_str(params.description, "description")?.to_string(), - url: optional_str(params.url, "url")?.map(str::to_string), - pass_percent: params.pass_percent, - minimum_turnout_percent: params.minimum_turnout_percent, - end_date: params.end_date_ms, - }) -} - -/// Fetches a decision proposal by id as a document handle, the stored proposal -/// `dash_sdk_decision_proposal_build_edit` edits. -/// -/// # Parameters -/// * `sdk_handle` - Handle to the SDK instance. -/// * `proposal_id` - The proposal's id, base58. -/// -/// # Returns -/// A document handle (`data_type` `ResultDocumentHandle`), or no data when there is no such -/// proposal. Free the handle with `dash_sdk_document_handle_destroy`. -/// -/// # Safety -/// - `sdk_handle` must be a valid pointer to an initialized `SDKHandle`. -/// - `proposal_id` must be a valid pointer to a NUL-terminated string for the duration of the -/// call. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_decision_proposal_fetch_document( - sdk_handle: *const SDKHandle, - proposal_id: *const c_char, -) -> DashSDKResult { - match fetch_document_inner(sdk_handle, proposal_id) { - Ok(Some(document)) => document_handle_result(document), - Ok(None) => DashSDKResult::success(std::ptr::null_mut()), - Err(error) => DashSDKResult::error(error.into()), - } -} - -unsafe fn fetch_document_inner( - sdk_handle: *const SDKHandle, - proposal_id: *const c_char, -) -> Result, FFIError> { - let wrapper = sdk_wrapper(sdk_handle)?; - let proposal_id = required_identifier(proposal_id, "proposal_id")?; - Ok(wrapper - .runtime - .block_on(wrapper.sdk.fetch_decision_proposal(proposal_id))?) -} - -fn document_handle_result(document: Document) -> DashSDKResult { - DashSDKResult::success_handle( - Box::into_raw(Box::new(document)) as *mut c_void, - DashSDKResultDataType::ResultDocumentHandle, - ) -} - -/// Gives a stored decision proposal new text: a copy of `proposal` with the text `edit` -/// sets and its next revision. Nothing is broadcast and `proposal` is left as it is. -/// -/// rs-sdk refuses, with `InvalidParameter`, a document that is not a `proposal` of the -/// decision proposals contract (as `data_contract_id` and `document_type_name` name it), an -/// edit that leaves the text as it is (a replace that changes nothing still pays its fees), -/// and a document past its last revision. Platform refuses the replace more than a day after -/// the proposal was created, and any change to its rule or end date. -/// -/// Put the edited document with `dash_sdk_document_put_to_platform_and_wait`: its revision is -/// above 1, so it is put as a replace, and the `entropy` argument is not read (pass any 32 -/// bytes). Do not use `dash_sdk_document_replace_on_platform_and_wait`, which moves the -/// revision once more and is refused. For the platform wallet, pass -/// `dash_sdk_decision_proposal_properties_json` of the edited document to -/// `platform_wallet_document_replace` with the proposal's id; it fetches the stored proposal -/// and moves it to the same revision. -/// -/// # Parameters -/// * `proposal` - The proposal as stored, from `dash_sdk_decision_proposal_fetch_document`. -/// * `data_contract_id` - The contract the document belongs to, base58 -/// (`dash_sdk_decision_proposal_contract_id`). -/// * `document_type_name` - The document's type (`dash_sdk_decision_proposal_document_type_name`). -/// * `edit` - The new text. -/// -/// # Returns -/// A new document handle (`data_type` `ResultDocumentHandle`). Free it with -/// `dash_sdk_document_handle_destroy`; the caller still owns and frees `proposal`. -/// -/// # Safety -/// - `proposal` must be a valid document handle from this SDK. -/// - `data_contract_id` and `document_type_name` must be valid pointers to NUL-terminated -/// strings. -/// - `edit` must be a valid pointer to a `DashSDKDecisionProposalEdit` whose string fields are -/// null or NUL-terminated. All must stay valid for the duration of the call. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_decision_proposal_build_edit( - proposal: *const DocumentHandle, - data_contract_id: *const c_char, - document_type_name: *const c_char, - edit: *const DashSDKDecisionProposalEdit, -) -> DashSDKResult { - match build_edit_inner(proposal, data_contract_id, document_type_name, edit) { - Ok(document) => document_handle_result(document), - Err(error) => DashSDKResult::error(error.into()), - } -} - -unsafe fn build_edit_inner( - proposal: *const DocumentHandle, - data_contract_id: *const c_char, - document_type_name: *const c_char, - edit: *const DashSDKDecisionProposalEdit, -) -> Result { - if proposal.is_null() { - return Err(FFIError::InvalidParameter("proposal is null".to_string())); - } - let proposal = &*(proposal as *const Document); - let data_contract_id = required_identifier(data_contract_id, "data_contract_id")?; - let document_type_name = required_str(document_type_name, "document_type_name")?; - let edit = proposal_edit(edit)?; - // The edit reads nothing but its arguments, so whatever it refuses is the caller's input. - build_decision_proposal_edit(&data_contract_id, document_type_name, proposal, &edit) - .map_err(|e| FFIError::InvalidParameter(e.to_string())) -} - -/// The edit `edit` holds. -/// -/// # Safety -/// `edit` must be null or point to a `DashSDKDecisionProposalEdit` whose string fields are -/// null or NUL-terminated. -unsafe fn proposal_edit( - edit: *const DashSDKDecisionProposalEdit, -) -> Result { - if edit.is_null() { - return Err(FFIError::InvalidParameter("edit is null".to_string())); - } - let edit = &*edit; - let url = match (optional_str(edit.url, "url")?, edit.remove_url) { - (Some(_), true) => { - return Err(FFIError::InvalidParameter( - "url and remove_url cannot both be set".to_string(), - )) - } - (Some(url), false) => Some(Some(url.to_string())), - (None, true) => Some(None), - (None, false) => None, - }; - Ok(DecisionProposalEdit { - title: optional_str(edit.title, "title")?.map(str::to_string), - description: optional_str(edit.description, "description")?.map(str::to_string), - url, - }) -} - -/// The properties of a proposal document as a JSON object: the `properties_json` the platform -/// wallet's `platform_wallet_create_document_with_signer` (for a document from -/// `dash_sdk_decision_proposal_build`) and `platform_wallet_document_replace` (for one from -/// `dash_sdk_decision_proposal_build_edit`) take, for example -/// `{"title": "...", "description": "...", "url": "...", "passPercent": 60, -/// "minimumTurnoutPercent": 10, "endDate": 1701209600000}`. A proposal without a url has no -/// `url` key. -/// -/// # Returns -/// A JSON string. Free it with `dash_sdk_string_free`; the caller still owns `document`. -/// -/// # Safety -/// `document` must be a valid document handle from this SDK. -#[no_mangle] -pub unsafe extern "C" fn dash_sdk_decision_proposal_properties_json( - document: *const DocumentHandle, -) -> DashSDKResult { - if document.is_null() { - return DashSDKResult::error( - FFIError::InvalidParameter("document is null".to_string()).into(), - ); - } - json_result(properties_json(&*(document as *const Document)).map(Some)) -} - /// Votes on a decision proposal for a masternode: builds the vote on the proposal's yes/no /// poll, signs it with the masternode voting key derived from `voting_private_key`, /// broadcasts it for `voter_pro_tx_hash` and waits for the response. Every argument is read @@ -471,21 +174,12 @@ unsafe fn cast_vote_inner( #[cfg(test)] mod tests { use super::*; - use crate::decision_proposal::json::tests::{ - input, proposals_contract, stored_proposal, END_DATE, - }; + use crate::decision_proposal::json::tests::proposals_contract; use crate::decision_proposal::json::vote_choice_name; - use crate::document::{build_document_from_properties, parse_document_properties_json}; use crate::test_utils::test_utils::{create_mock_sdk_handle, destroy_mock_sdk_handle}; - use crate::{ - dash_sdk_document_create_result_free, dash_sdk_document_handle_destroy, - dash_sdk_error_free, dash_sdk_string_free, DashSDKErrorCode, - }; + use crate::{dash_sdk_error_free, DashSDKErrorCode}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; - use dash_sdk::dpp::document::{DocumentV0Getters, DocumentV0Setters}; - use dash_sdk::dpp::platform_value::{Bytes32, Identifier}; - use dash_sdk::dpp::version::PlatformVersion; - use dash_sdk::platform::decision_proposals::{DecisionProposal, DecisionProposalInput}; + use dash_sdk::dpp::platform_value::Identifier; use std::ffi::CStr; /// `result`'s error code; the error is freed. @@ -497,64 +191,10 @@ mod tests { code } - /// The document `result` holds as a handle; the handle is freed. - unsafe fn take_document(result: DashSDKResult) -> Document { - assert!(result.error.is_null(), "{:?}", error_message(&result)); - assert_eq!( - result.data_type, - DashSDKResultDataType::ResultDocumentHandle - ); - let document = (*(result.data as *const Document)).clone(); - dash_sdk_document_handle_destroy(result.data as *mut DocumentHandle); - document - } - - unsafe fn error_message(result: &DashSDKResult) -> Option { - (!result.error.is_null()).then(|| { - CStr::from_ptr((*result.error).message) - .to_string_lossy() - .into_owned() - }) - } - - /// The string `result` holds; the string is freed. - unsafe fn take_string(result: DashSDKResult) -> String { - assert!(result.error.is_null(), "{:?}", error_message(&result)); - assert_eq!(result.data_type, DashSDKResultDataType::String); - let string = CStr::from_ptr(result.data as *const c_char) - .to_str() - .expect("UTF-8") - .to_string(); - dash_sdk_string_free(result.data as *mut c_char); - string - } - fn c_string(value: &str) -> CString { CString::new(value).expect("no NUL") } - fn no_edit() -> DashSDKDecisionProposalEdit { - DashSDKDecisionProposalEdit { - title: std::ptr::null(), - description: std::ptr::null(), - url: std::ptr::null(), - remove_url: false, - } - } - - /// `edit` applied by the FFI to `proposal`, named as a decision proposal. - unsafe fn edit_proposal( - proposal: &Document, - edit: &DashSDKDecisionProposalEdit, - ) -> DashSDKResult { - dash_sdk_decision_proposal_build_edit( - proposal as *const Document as *const DocumentHandle, - dash_sdk_decision_proposal_contract_id(), - dash_sdk_decision_proposal_document_type_name(), - edit, - ) - } - #[test] fn should_name_the_decision_proposals_contract_and_the_proposal_type() { let contract = proposals_contract(); @@ -612,270 +252,6 @@ mod tests { } } - #[test] - fn should_read_the_proposal_params() { - let title = c_string("Raise the block size?"); - let description = c_string("Yes doubles it; no keeps it."); - let url = c_string("https://forum.dash.org/t/block-size"); - let mut params = DashSDKDecisionProposalParams { - title: title.as_ptr(), - description: description.as_ptr(), - url: url.as_ptr(), - pass_percent: 60, - minimum_turnout_percent: 10, - end_date_ms: END_DATE, - }; - unsafe { - assert_eq!(proposal_input(¶ms).expect("reads"), input()); - params.url = std::ptr::null(); - assert_eq!(proposal_input(¶ms).expect("reads").url, None); - params.title = std::ptr::null(); - assert!(matches!( - proposal_input(¶ms), - Err(FFIError::InvalidParameter(_)) - )); - assert!(matches!( - proposal_input(std::ptr::null()), - Err(FFIError::InvalidParameter(_)) - )); - } - } - - /// The build refuses arguments it cannot read before it asks the network anything. - #[test] - fn should_refuse_a_build_with_unreadable_arguments() { - let handle = create_mock_sdk_handle(); - let owner = c_string(&Identifier::new([3; 32]).to_string(Encoding::Base58)); - let not_base58 = c_string("0OIl"); - let title = c_string("Raise the block size?"); - let description = c_string("Yes doubles it; no keeps it."); - let params = DashSDKDecisionProposalParams { - title: title.as_ptr(), - description: description.as_ptr(), - url: std::ptr::null(), - pass_percent: 60, - minimum_turnout_percent: 10, - end_date_ms: END_DATE, - }; - let no_title = DashSDKDecisionProposalParams { - title: std::ptr::null(), - ..params - }; - unsafe { - for result in [ - dash_sdk_decision_proposal_build(std::ptr::null(), owner.as_ptr(), ¶ms), - dash_sdk_decision_proposal_build(handle, std::ptr::null(), ¶ms), - dash_sdk_decision_proposal_build(handle, not_base58.as_ptr(), ¶ms), - dash_sdk_decision_proposal_build(handle, owner.as_ptr(), std::ptr::null()), - dash_sdk_decision_proposal_build(handle, owner.as_ptr(), &no_title), - ] { - assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); - } - destroy_mock_sdk_handle(handle); - } - } - - #[test] - fn should_give_a_proposal_new_text_and_its_next_revision() { - let proposal = stored_proposal(&input()); - let title = c_string("Raise the block size to 4 MB?"); - let url = c_string("https://forum.dash.org/t/block-size-4mb"); - unsafe { - let edited = take_document(edit_proposal( - &proposal, - &DashSDKDecisionProposalEdit { - title: title.as_ptr(), - url: url.as_ptr(), - ..no_edit() - }, - )); - assert_eq!(edited.id(), proposal.id()); - assert_eq!(edited.revision(), Some(2)); - let read = DecisionProposal::from_document(edited).expect("a proposal"); - assert_eq!(read.title, "Raise the block size to 4 MB?"); - assert_eq!(read.description, input().description); - assert_eq!( - read.url.as_deref(), - Some("https://forum.dash.org/t/block-size-4mb") - ); - assert_eq!( - ( - read.pass_percent, - read.minimum_turnout_percent, - read.end_date - ), - (60, 10, END_DATE) - ); - // The stored proposal the caller passed is left as it was - assert_eq!(proposal.revision(), Some(1)); - - let without_url = take_document(edit_proposal( - &proposal, - &DashSDKDecisionProposalEdit { - remove_url: true, - ..no_edit() - }, - )); - assert_eq!( - DecisionProposal::from_document(without_url) - .expect("a proposal") - .url, - None - ); - } - } - - /// rs-sdk refuses these edits; the FFI passes its refusal on as `InvalidParameter`. - #[test] - fn should_refuse_an_edit_that_changes_nothing_or_is_not_of_a_proposal() { - let proposal = stored_proposal(&input()); - let same_title = c_string(&input().title); - let new_title = c_string("Another question?"); - let same_url = c_string(input().url.as_deref().expect("a url")); - let other_contract = c_string(&Identifier::new([1; 32]).to_string(Encoding::Base58)); - let other_type = c_string("note"); - unsafe { - for edit in [ - no_edit(), - DashSDKDecisionProposalEdit { - title: same_title.as_ptr(), - ..no_edit() - }, - DashSDKDecisionProposalEdit { - url: same_url.as_ptr(), - ..no_edit() - }, - ] { - let result = edit_proposal(&proposal, &edit); - assert!(error_message(&result) - .expect("refused") - .contains("changes nothing")); - assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); - } - - let retitle = DashSDKDecisionProposalEdit { - title: new_title.as_ptr(), - ..no_edit() - }; - for (data_contract_id, document_type_name) in [ - ( - other_contract.as_ptr(), - dash_sdk_decision_proposal_document_type_name(), - ), - ( - dash_sdk_decision_proposal_contract_id(), - other_type.as_ptr(), - ), - ] { - let result = dash_sdk_decision_proposal_build_edit( - &proposal as *const Document as *const DocumentHandle, - data_contract_id, - document_type_name, - &retitle, - ); - assert!(error_message(&result) - .expect("refused") - .contains("is not a decision proposal")); - assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); - } - - let mut last_revision = proposal.clone(); - last_revision.set_revision(Some(u64::MAX)); - assert_eq!( - error_code(edit_proposal(&last_revision, &retitle)), - DashSDKErrorCode::InvalidParameter - ); - } - } - - #[test] - fn should_refuse_an_edit_it_cannot_read() { - let proposal = stored_proposal(&input()); - let url = c_string("https://example.com"); - let not_base58 = c_string("0OIl"); - let contradictory = DashSDKDecisionProposalEdit { - url: url.as_ptr(), - remove_url: true, - ..no_edit() - }; - let proposal_handle = &proposal as *const Document as *const DocumentHandle; - unsafe { - for result in [ - edit_proposal(&proposal, &contradictory), - dash_sdk_decision_proposal_build_edit( - std::ptr::null(), - dash_sdk_decision_proposal_contract_id(), - dash_sdk_decision_proposal_document_type_name(), - &no_edit(), - ), - dash_sdk_decision_proposal_build_edit( - proposal_handle, - not_base58.as_ptr(), - dash_sdk_decision_proposal_document_type_name(), - &no_edit(), - ), - dash_sdk_decision_proposal_build_edit( - proposal_handle, - dash_sdk_decision_proposal_contract_id(), - std::ptr::null(), - &no_edit(), - ), - dash_sdk_decision_proposal_build_edit( - proposal_handle, - dash_sdk_decision_proposal_contract_id(), - dash_sdk_decision_proposal_document_type_name(), - std::ptr::null(), - ), - ] { - assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); - } - } - } - - /// The properties JSON, read back the way a document create reads its properties (the - /// platform wallet's create and `dash_sdk_document_create` share the parse, sanitize and - /// build steps), gives a document with exactly the properties rs-sdk built, value types - /// included. - #[test] - fn should_write_properties_a_document_create_reads_back_into_the_same_proposal() { - let platform_version = PlatformVersion::latest(); - let contract = proposals_contract(); - let document_type = contract - .document_type_for_name("proposal") - .expect("the proposal type"); - for proposal in [ - stored_proposal(&input()), - stored_proposal(&DecisionProposalInput { - url: None, - ..input() - }), - ] { - unsafe { - let json = take_string(dash_sdk_decision_proposal_properties_json( - &proposal as *const Document as *const DocumentHandle, - )); - let properties = parse_document_properties_json(&json) - .ok() - .expect("an object"); - let created = build_document_from_properties( - document_type, - properties, - proposal.owner_id(), - [9; 32], - platform_version, - ) - .expect("builds"); - assert_eq!(created.properties(), proposal.properties(), "{json}"); - } - } - unsafe { - assert_eq!( - error_code(dash_sdk_decision_proposal_properties_json(std::ptr::null())), - DashSDKErrorCode::InvalidParameter - ); - } - } - /// The vote reads every argument before it builds the vote, which asks the network for /// its protocol version. #[test] @@ -950,42 +326,4 @@ mod tests { destroy_mock_sdk_handle(handle); } } - - #[test] - fn should_refuse_to_fetch_a_document_with_unreadable_arguments() { - let handle = create_mock_sdk_handle(); - let not_base58 = c_string("0OIl"); - let id = c_string(&Identifier::new([4; 32]).to_string(Encoding::Base58)); - unsafe { - for result in [ - dash_sdk_decision_proposal_fetch_document(std::ptr::null(), id.as_ptr()), - dash_sdk_decision_proposal_fetch_document(handle, std::ptr::null()), - dash_sdk_decision_proposal_fetch_document(handle, not_base58.as_ptr()), - ] { - assert_eq!(error_code(result), DashSDKErrorCode::InvalidParameter); - } - destroy_mock_sdk_handle(handle); - } - } - - /// The built proposal reaches the caller as `dash_sdk_document_create`'s result does, and - /// frees the same way: the struct, then the document handle it held. - #[test] - fn should_hand_a_built_proposal_over_as_a_document_create_result() { - let document = stored_proposal(&input()); - let result = create_result(DecisionProposalDocument { - document: document.clone(), - entropy: Bytes32::new([9; 32]), - }); - assert!(result.error.is_null()); - assert_eq!(result.data_type, DashSDKResultDataType::NoData); - unsafe { - let created = result.data as *mut DashSDKDocumentCreateResult; - assert_eq!((*created).entropy, [9; 32]); - let handle = (*created).document_handle; - dash_sdk_document_create_result_free(created); - assert_eq!(*(handle as *const Document), document); - dash_sdk_document_handle_destroy(handle); - } - } } diff --git a/packages/rs-sdk-ffi/src/document/mod.rs b/packages/rs-sdk-ffi/src/document/mod.rs index fb2d40a6775..2a388bae42d 100644 --- a/packages/rs-sdk-ffi/src/document/mod.rs +++ b/packages/rs-sdk-ffi/src/document/mod.rs @@ -12,10 +12,7 @@ mod transfer; mod util; // Re-export functions from submodules -pub use create::{ - dash_sdk_document_create, dash_sdk_document_create_result_free, DashSDKDocumentCreateParams, - DashSDKDocumentCreateResult, -}; +pub use create::{dash_sdk_document_create, DashSDKDocumentCreateParams}; pub use delete::{dash_sdk_document_delete, dash_sdk_document_delete_and_wait}; pub use price::{ dash_sdk_document_update_price_of_document, dash_sdk_document_update_price_of_document_and_wait, diff --git a/packages/rs-unified-sdk-jni/src/decision_proposals.rs b/packages/rs-unified-sdk-jni/src/decision_proposals.rs index 678b9a29c40..dcba5e51fa7 100644 --- a/packages/rs-unified-sdk-jni/src/decision_proposals.rs +++ b/packages/rs-unified-sdk-jni/src/decision_proposals.rs @@ -1,52 +1,44 @@ //! JNI exports for decision proposals (protocol version 15): yes/no questions any identity //! puts to the masternodes, as `proposal` documents of the decision proposals system -//! contract. Each export marshals ONE `dash_sdk_decision_proposal_*` function of -//! `rs-sdk-ffi` (or the document free its handles need): it reads the JVM arguments, calls -//! the C function, copies the result into a JVM value and frees every Rust allocation the -//! result carried before returning. +//! contract. Each export marshals ONE C function: a `dash_sdk_decision_proposal_*` function +//! of `rs-sdk-ffi` (the contract's names, the reads and the masternode vote), or +//! `platform_wallet_decision_proposal_create` / `_edit` of `platform-wallet-ffi` (the create +//! and the edit, which the platform wallet builds, signs, broadcasts and confirms itself). It +//! reads the JVM arguments, calls the C function, copies the result into a JVM value and +//! frees every Rust allocation the result carried before returning. //! -//! Creating and editing a proposal are sequences of these calls, run by Kotlin -//! (`voting.DecisionProposalTransactions`), never stitched together here: build (or fetch and -//! edit) a document, read its properties JSON, free the document handles, then put the -//! properties through the platform wallet's document create or replace -//! (`TransactionsNative.documentCreate` / `documentReplace`). Document handles cross as -//! `jlong` and are released with `documentFree`. -//! -//! Errors follow the rs-sdk-ffi convention of `queries.rs`: a `DashSDKResult` error throws -//! `DashSDKException` through [`crate::results::take_error`]. An argument the JVM value -//! cannot carry into the C type (a null required string, a byte array of the wrong length, -//! a number out of the C type's range) throws code 1 (`InvalidParameter`) before any C call; -//! everything rs-sdk-ffi and rs-sdk check is left to them. +//! Errors follow the convention of the C function's crate: a `DashSDKResult` error throws +//! `DashSDKException` through [`crate::results::take_error`], and a `PlatformWalletFFIResult` +//! error through [`crate::support::take_pwffi_error`] (its code shifted by +//! `PWFFI_CODE_OFFSET`, a refusal by Platform carrying its consensus code). An argument the +//! JVM value cannot carry into the C type (a null required string, a byte array of the wrong +//! length, a number out of the C type's range) throws code 1 (`InvalidParameter`) before any +//! C call; everything the C functions and Rust behind them check is left to them. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DecisionProposalsNative`. -use crate::results::{take_error, unwrap_handle, unwrap_string}; -use crate::support::{guard, net_from_ord, throw_sdk_exception}; -use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jbyteArray, jint, jlong, jobject, jstring}; +use crate::results::{take_error, unwrap_string}; +use crate::support::{guard, net_from_ord, take_pwffi_error, throw_sdk_exception}; +use jni::objects::{JByteArray, JClass, JString}; +use jni::sys::{jboolean, jbyte, jbyteArray, jint, jlong, jstring}; use jni::JNIEnv; +use platform_wallet_ffi::handle::Handle; +use platform_wallet_ffi::{ + platform_wallet_decision_proposal_create, platform_wallet_decision_proposal_edit, + platform_wallet_string_free, DecisionProposalEditFFI, DecisionProposalParamsFFI, +}; use rs_sdk_ffi::{ - dash_sdk_decision_proposal_build, dash_sdk_decision_proposal_build_edit, dash_sdk_decision_proposal_cast_vote, dash_sdk_decision_proposal_contract_id, dash_sdk_decision_proposal_contract_id_bytes, dash_sdk_decision_proposal_document_type_name, - dash_sdk_decision_proposal_fetch, dash_sdk_decision_proposal_fetch_document, - dash_sdk_decision_proposal_get_masternode_vote, dash_sdk_decision_proposal_get_vote_poll_id, - dash_sdk_decision_proposal_get_vote_poll_state, dash_sdk_decision_proposal_list_by_created_at, - dash_sdk_decision_proposal_list_by_end_date, dash_sdk_decision_proposal_list_by_owner, - dash_sdk_decision_proposal_properties_json, dash_sdk_document_create_result_free, - dash_sdk_document_handle_destroy, DashSDKDecisionProposalEdit, DashSDKDecisionProposalParams, - DashSDKDocumentCreateResult, DashSDKResult, DocumentHandle, SDKHandle, + dash_sdk_decision_proposal_fetch, dash_sdk_decision_proposal_get_masternode_vote, + dash_sdk_decision_proposal_get_vote_poll_id, dash_sdk_decision_proposal_get_vote_poll_state, + dash_sdk_decision_proposal_list_by_created_at, dash_sdk_decision_proposal_list_by_end_date, + dash_sdk_decision_proposal_list_by_owner, DashSDKResult, SDKHandle, SignerHandle, }; use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; -/// JNI-internal name of the Kotlin class `build` returns. -const BUILD_RESULT_CLASS: &str = "org/dashfoundation/dashsdk/ffi/DecisionProposalBuildNativeResult"; - -/// Constructor descriptor of [`BUILD_RESULT_CLASS`]: `(documentHandle: Long, entropy: ByteArray)`. -const BUILD_RESULT_CONSTRUCTOR: &str = "(J[B)V"; - // ── Marshalling helpers ────────────────────────────────────────────────── /// Read a nullable JVM string into an owned `CString`. @@ -180,29 +172,47 @@ unsafe fn static_string(env: &mut JNIEnv, value: *const c_char) -> jstring { .unwrap_or(ptr::null_mut()) } -/// Frees a document handle on drop unless disarmed: it holds a handle from the C call that -/// returned it until the JVM value carrying it to Kotlin exists, so no failure in between -/// leaks it. -struct DocumentHandleGuard(*mut DocumentHandle); - -impl DocumentHandleGuard { - /// Hands the handle on; the guard no longer frees it. - fn disarm(&mut self) -> *mut DocumentHandle { - std::mem::replace(&mut self.0, ptr::null_mut()) +/// Whether `arr` is a non-null `byte[len]`. Clears the exception a failed length read +/// leaves; the caller throws. +fn is_byte_array_of_len(env: &mut JNIEnv, arr: &JByteArray, len: i32) -> bool { + if arr.is_null() { + return false; + } + match env.get_array_length(arr) { + Ok(actual) => actual == len, + Err(_) => { + let _ = env.exception_clear(); + false + } } } -impl Drop for DocumentHandleGuard { - fn drop(&mut self) { - // SAFETY: the handle came from rs-sdk-ffi and nothing else owns it; null is a no-op. - unsafe { dash_sdk_document_handle_destroy(self.0) }; +/// The confirmed document JSON a platform wallet write handed over through its +/// `out_document_json`, copied into a Rust string before the C string is freed with +/// `platform_wallet_string_free`. Throws and returns `None` when a successful call left it +/// null. +/// +/// # Safety +/// `json` must be null or a string the platform wallet allocated and nothing else frees. +unsafe fn take_wallet_json(env: &mut JNIEnv, json: *mut c_char, call: &str) -> Option { + if json.is_null() { + throw_sdk_exception( + env, + 99, + &format!("{call} returned success but no document JSON"), + ); + return None; } + let copied = CStr::from_ptr(json).to_string_lossy().into_owned(); + platform_wallet_string_free(json); + Some(copied) } // ── Constants ──────────────────────────────────────────────────────────── /// The id of the decision proposals system contract, base58 -/// (`dash_sdk_decision_proposal_contract_id`): the `dataContractId` `buildEdit` takes. +/// (`dash_sdk_decision_proposal_contract_id`): the `dataContractId` the generic document +/// reads take to read proposals. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_contractId( mut env: JNIEnv, @@ -214,8 +224,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNati } /// The 32 bytes of the decision proposals contract id -/// (`dash_sdk_decision_proposal_contract_id_bytes`): the `contractId` the platform wallet's -/// document create and replace take. +/// (`dash_sdk_decision_proposal_contract_id_bytes`), for the calls that take a contract id +/// as bytes. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_contractIdBytes( mut env: JNIEnv, @@ -249,35 +259,46 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNati }) } -// ── Create ─────────────────────────────────────────────────────────────── +// ── Create and edit (platform wallet) ──────────────────────────────────── -/// Build a decision proposal of `ownerIdentityId` (`dash_sdk_decision_proposal_build`). -/// Nothing is broadcast. -/// -/// Returns a `DecisionProposalBuildNativeResult(documentHandle, entropy)`: the C result -/// struct is freed here with `dash_sdk_document_create_result_free`, which leaves the -/// document alive, and the document handle passes to Kotlin, which frees it with -/// `documentFree`. If the result object cannot be made, the handle is freed here before -/// throwing. Null after throwing. +/// Create + broadcast a decision proposal of `ownerId`, signed via `signerHandle`: the JNI +/// bridge over `platform_wallet_decision_proposal_create` (Swift +/// `ManagedPlatformWallet.createDecisionProposal`). The wallet builds the proposal, whose +/// create states the decision proposal fund (paid by the owner on top of the fees), picks +/// the signing key itself, broadcasts and waits for Platform to confirm. /// /// `url` may be null (no url). `passPercent` and `minimumTurnoutPercent` must fit a `u8` and -/// `endDateMs` a `u64`; the limits on them are Platform's. +/// `endDateMs` a `u64`; the limits on them are Platform's. `outProposalId` must be a +/// non-null `byte[32]`, checked before anything is broadcast so that the id of a paid create +/// can always be written back; it receives the proposal's id. +/// +/// Returns the confirmed document's canonical JSON (the shape +/// `TransactionsNative.documentCreate` returns), its Rust string freed here. Null after +/// throwing; a refusal by Platform carries its consensus code. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_build( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_create( mut env: JNIEnv, _class: JClass, - sdk: jlong, - owner_identity_id: JString, + wallet_handle: jlong, + owner_id: JByteArray, title: JString, description: JString, url: JString, pass_percent: jint, minimum_turnout_percent: jint, end_date_ms: jlong, -) -> jobject { + signer_handle: jlong, + out_proposal_id: JByteArray, +) -> jstring { guard(&mut env, ptr::null_mut(), |env| { - let Some(owner) = read_cstring(env, &owner_identity_id, "ownerIdentityId") else { + // Checked before the paid create: once it succeeded, writing the id back into a + // validated array allocates nothing and cannot go out of bounds. + if !is_byte_array_of_len(env, &out_proposal_id, 32) { + throw_sdk_exception(env, 1, "outProposalId must be a non-null byte[32]"); + return ptr::null_mut(); + } + let Some(owner) = read_bytes32(env, &owner_id, "ownerId") else { return ptr::null_mut(); }; let Some(title) = read_cstring(env, &title, "title") else { @@ -301,7 +322,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNati return ptr::null_mut(); }; - let params = DashSDKDecisionProposalParams { + let params = DecisionProposalParamsFFI { title: title.as_ptr(), description: description.as_ptr(), url: c_ptr(&url), @@ -309,175 +330,125 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNati minimum_turnout_percent, end_date_ms, }; + let mut proposal_id = [0u8; 32]; + let mut out_json: *mut c_char = ptr::null_mut(); + // SAFETY: `owner` and `proposal_id` are 32 bytes, `params` and the strings it points + // to outlive the call, and the signer handle is the Kotlin manager's live signer. let result = unsafe { - dash_sdk_decision_proposal_build(sdk as *const SDKHandle, owner.as_ptr(), ¶ms) + platform_wallet_decision_proposal_create( + wallet_handle as Handle, + owner.as_ptr(), + ¶ms, + signer_handle as *mut SignerHandle, + proposal_id.as_mut_ptr(), + &mut out_json, + ) }; - unsafe { build_result_object(env, result) } - }) -} - -/// The `DecisionProposalBuildNativeResult` for the result of -/// `dash_sdk_decision_proposal_build`, whose payload is a `DashSDKDocumentCreateResult`. -/// The struct is freed; the document handle is handed to Kotlin, or freed when the object -/// cannot be made. Null after throwing. -/// -/// # Safety -/// `result` must be freshly returned by `dash_sdk_decision_proposal_build`. -unsafe fn build_result_object(env: &mut JNIEnv, result: DashSDKResult) -> jobject { - if take_error(env, &result) { - return ptr::null_mut(); - } - if result.data.is_null() { - throw_sdk_exception( - env, - 99, - "decision proposal build returned success but no document", - ); - return ptr::null_mut(); - } - let created = result.data as *mut DashSDKDocumentCreateResult; - // Take the handle into the guard before the struct is freed, so it is never unowned. - let mut document = DocumentHandleGuard((*created).document_handle); - let entropy = (*created).entropy; - // Frees the struct only; the document stays alive in the guard. - dash_sdk_document_create_result_free(created); - - let Ok(entropy) = env.byte_array_from_slice(&entropy) else { - let _ = env.exception_clear(); - throw_sdk_exception( - env, - 99, - "decision proposal entropy byte[] allocation failed", - ); - return ptr::null_mut(); - }; - let object = env.new_object( - BUILD_RESULT_CLASS, - BUILD_RESULT_CONSTRUCTOR, - &[ - JValue::Long(document.0 as jlong), - JValue::Object(entropy.as_ref()), - ], - ); - match object { - Ok(object) => { - document.disarm(); - object.into_raw() + if take_pwffi_error(env, result) { + return ptr::null_mut(); } - Err(_) => { + // SAFETY: on success `out_json` is the wallet's string, freed by this call only. + let Some(json) = (unsafe { take_wallet_json(env, out_json, "decision proposal create") }) + else { + return ptr::null_mut(); + }; + + let id = proposal_id.map(|b| b as jbyte); + if env.set_byte_array_region(&out_proposal_id, 0, &id).is_err() { + // Unreachable after the check above; kept as a backstop that says the proposal + // exists, so a caller does not retry a paid create. let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "decision proposal build result allocation failed"); - ptr::null_mut() + throw_sdk_exception( + env, + 99, + "the decision proposal was created, but its id could not be returned", + ); + return ptr::null_mut(); } - } -} - -/// The properties of a proposal document as a JSON object -/// (`dash_sdk_decision_proposal_properties_json`): the `propertiesJson` the platform wallet's -/// document create (for a document from `build`) and replace (for one from `buildEdit`) -/// take. The caller still owns and frees `document`. Null after throwing. -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_propertiesJson( - mut env: JNIEnv, - _class: JClass, - document: jlong, -) -> jstring { - guard(&mut env, ptr::null_mut(), |env| unsafe { - let result = dash_sdk_decision_proposal_properties_json(document as *const DocumentHandle); - string_or_null(env, result) - }) -} - -// ── Edit ───────────────────────────────────────────────────────────────── - -/// Fetch a decision proposal as a document handle -/// (`dash_sdk_decision_proposal_fetch_document`), the stored proposal `buildEdit` edits. -/// Returns 0 when there is no such proposal, and after throwing. Free a non-zero handle with -/// `documentFree`. -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_fetchDocument( - mut env: JNIEnv, - _class: JClass, - sdk: jlong, - proposal_id: JString, -) -> jlong { - guard(&mut env, 0, |env| { - let Some(id) = read_cstring(env, &proposal_id, "proposalId") else { - return 0; - }; - let result = unsafe { - dash_sdk_decision_proposal_fetch_document(sdk as *const SDKHandle, id.as_ptr()) - }; - unsafe { unwrap_handle(env, result) } + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) }) } -/// A copy of the stored proposal `proposal` with new text and its next revision -/// (`dash_sdk_decision_proposal_build_edit`). Pure: no network. `title`, `description` and -/// `url` null keep their value; `removeUrl` removes the url. Returns a new document handle, -/// 0 after throwing; the caller still owns and frees `proposal`, and frees the new handle -/// with `documentFree`. +/// Give the decision proposal `proposalId` of `ownerId` new text and broadcast the replace, +/// signed via `signerHandle`: the JNI bridge over `platform_wallet_decision_proposal_edit` +/// (Swift `ManagedPlatformWallet.editDecisionProposal`). The wallet reads the proposal once, +/// edits it at the revision after the one read and picks the signing key as the create +/// does; if another edit landed in between, Platform refuses this one (consensus code +/// 40106) rather than overwrite it. +/// +/// `title`, `description` and `url` null keep their value; `removeUrl` removes the url +/// (the wallet refuses it together with a non-null `url`). `ownerId` and `proposalId` are +/// 32-byte ids. +/// +/// Returns the confirmed document's canonical JSON at its new revision, its Rust string +/// freed here. Null after throwing; a proposal that does not exist is the wallet's +/// `NotFound`. #[no_mangle] #[allow(clippy::too_many_arguments)] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_buildEdit( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_edit( mut env: JNIEnv, _class: JClass, - proposal: jlong, - data_contract_id: JString, - document_type_name: JString, + wallet_handle: jlong, + owner_id: JByteArray, + proposal_id: JByteArray, title: JString, description: JString, url: JString, remove_url: jboolean, -) -> jlong { - guard(&mut env, 0, |env| { - let Some(contract_id) = read_cstring(env, &data_contract_id, "dataContractId") else { - return 0; + signer_handle: jlong, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(owner) = read_bytes32(env, &owner_id, "ownerId") else { + return ptr::null_mut(); }; - let Some(type_name) = read_cstring(env, &document_type_name, "documentTypeName") else { - return 0; + let Some(proposal) = read_bytes32(env, &proposal_id, "proposalId") else { + return ptr::null_mut(); }; let Some(title) = read_optional_cstring(env, &title, "title") else { - return 0; + return ptr::null_mut(); }; let Some(description) = read_optional_cstring(env, &description, "description") else { - return 0; + return ptr::null_mut(); }; let Some(url) = read_optional_cstring(env, &url, "url") else { - return 0; + return ptr::null_mut(); }; - let edit = DashSDKDecisionProposalEdit { + let edit = DecisionProposalEditFFI { title: c_ptr(&title), description: c_ptr(&description), url: c_ptr(&url), remove_url: remove_url != 0, }; + let mut out_json: *mut c_char = ptr::null_mut(); + // SAFETY: `owner` and `proposal` are 32 bytes, `edit` and the strings it points to + // outlive the call, and the signer handle is the Kotlin manager's live signer. let result = unsafe { - dash_sdk_decision_proposal_build_edit( - proposal as *const DocumentHandle, - contract_id.as_ptr(), - type_name.as_ptr(), + platform_wallet_decision_proposal_edit( + wallet_handle as Handle, + owner.as_ptr(), + proposal.as_ptr(), &edit, + signer_handle as *mut SignerHandle, + &mut out_json, ) }; - unsafe { unwrap_handle(env, result) } + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + // SAFETY: on success `out_json` is the wallet's string, freed by this call only. + let Some(json) = (unsafe { take_wallet_json(env, out_json, "decision proposal edit") }) + else { + return ptr::null_mut(); + }; + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()) }) } -/// Free a document handle from `build`, `fetchDocument` or `buildEdit` -/// (`dash_sdk_document_handle_destroy`, which `dash_sdk_document_free` aliases). Safe on 0. -#[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNative_documentFree( - mut env: JNIEnv, - _class: JClass, - document: jlong, -) { - guard(&mut env, (), |_| unsafe { - dash_sdk_document_handle_destroy(document as *mut DocumentHandle) - }); -} - // ── Reads ──────────────────────────────────────────────────────────────── /// A decision proposal by id as the proposal JSON object @@ -734,46 +705,83 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DecisionProposalsNati #[cfg(test)] mod tests { - use super::{BUILD_RESULT_CLASS, BUILD_RESULT_CONSTRUCTOR}; - const DECISION_PROPOSALS_NATIVE_KT: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DecisionProposalsNative.kt" )); - /// `build` constructs the Kotlin result by class name and descriptor, which only an - /// instrumented test would otherwise catch drifting: the class must sit where - /// [`BUILD_RESULT_CLASS`] says and take `(Long, ByteArray)` in that order. - #[test] - fn should_construct_the_build_result_kotlin_declares() { - let (package, class) = BUILD_RESULT_CLASS - .rsplit_once('/') - .expect("a JNI class name has a package"); - assert!( - DECISION_PROPOSALS_NATIVE_KT - .lines() - .any(|line| line.trim() == format!("package {}", package.replace('/', "."))), - "DecisionProposalsNative.kt is not in package {package}" - ); + /// The parameter types and the return type of `external fun {name}(...)` in + /// `DecisionProposalsNative.kt`. + fn kotlin_signature(name: &str) -> (Vec, String) { let declaration = DECISION_PROPOSALS_NATIVE_KT - .split(&format!("class {class}(")) + .split(&format!("external fun {name}(")) .nth(1) - .expect("DecisionProposalsNative.kt declares the build result class"); - let parameters: Vec<&str> = declaration - .split(')') - .next() - .expect("the constructor has a parameter list") - .lines() + .unwrap_or_else(|| panic!("DecisionProposalsNative.kt declares {name}")); + let (parameters, rest) = declaration + .split_once(')') + .expect("the parameter list closes"); + let types = parameters + .split(',') .map(str::trim) - .filter(|line| !line.is_empty()) + .filter(|parameter| !parameter.is_empty()) + .map(|parameter| { + let (_, kind) = parameter + .split_once(':') + .expect("a parameter is `name: Type`"); + kind.trim().to_string() + }) .collect(); + let returns = rest + .lines() + .next() + .and_then(|line| line.trim().strip_prefix(':')) + .map_or_else(|| "Unit".to_string(), |kind| kind.trim().to_string()); + (types, returns) + } + + /// JNI binds an export by its name alone: a Kotlin declaration whose parameters drifted + /// from the export's would hand it the wrong JVM values at run time, which only an + /// instrumented test would otherwise catch. The wallet writes take the most arguments, + /// and the create's last is the id out-array. + #[test] + fn should_declare_the_wallet_writes_as_the_exports_take_them() { + assert_eq!( + kotlin_signature("create"), + ( + [ + "Long", // wallet_handle + "ByteArray", // owner_id + "String", // title + "String", // description + "String?", // url + "Int", // pass_percent + "Int", // minimum_turnout_percent + "Long", // end_date_ms + "Long", // signer_handle + "ByteArray", // out_proposal_id + ] + .map(String::from) + .to_vec(), + "String".to_string() + ) + ); assert_eq!( - parameters, - [ - "@JvmField val documentHandle: Long,", - "@JvmField val entropy: ByteArray," - ] + kotlin_signature("edit"), + ( + [ + "Long", // wallet_handle + "ByteArray", // owner_id + "ByteArray", // proposal_id + "String?", // title + "String?", // description + "String?", // url + "Boolean", // remove_url + "Long", // signer_handle + ] + .map(String::from) + .to_vec(), + "String".to_string() + ) ); - assert_eq!(BUILD_RESULT_CONSTRUCTOR, "(J[B)V"); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift index 920b0435105..2867ecfa838 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWalletDecisionProposals.swift @@ -1,92 +1,226 @@ import Foundation +import DashSDKFFI // MARK: - Decision proposals (protocol version 15) // -// A decision proposal is a `proposal` document of the decision proposals -// system contract, so it is created and edited through the platform wallet's -// document create and replace, the same path every other document takes: -// the Rust side signs through the `KeychainSigner` and the signing key never -// crosses into Swift logic. rs-sdk builds the properties -// (`SDK.buildDecisionProposal`, `DecisionProposalDocument.edited`); these -// wrappers pass them on with the contract id and type name the FFI names. +// Creating and editing a decision proposal are platform wallet operations +// (`platform_wallet_decision_proposal_create` / `_edit`, over +// `IdentityWallet::create_decision_proposal_with_signer` / +// `edit_decision_proposal_with_signer`). rs-sdk and the wallet decide +// everything: the proposal's properties, the decision proposal fund the +// create states, the signing key, refusing an edit that changes nothing, and +// the revision an edit is put at. These wrappers marshal the arguments in and +// the confirmed document out; the signing key never crosses into Swift logic, +// the `KeychainSigner` trampoline services the signature on demand. extension ManagedPlatformWallet { - /// Create + broadcast the decision proposal `proposal`, built by - /// ``SDK/buildDecisionProposal(ownerIdentityId:params:)`` for - /// `ownerIdentityId`. Returns the proposal's 32-byte id and the confirmed - /// document's canonical JSON once Platform confirms the transition. + /// Create + broadcast a decision proposal of `ownerIdentityId` asking what + /// `params` says. Returns the proposal's 32-byte id and the confirmed + /// document's canonical JSON (the shape + /// ``createDocument(ownerIdentityId:contractId:documentType:propertiesJSON:signer:maxContestFund:)`` + /// returns) once Platform confirms the transition. /// - /// Routes through ``createDocument(ownerIdentityId:contractId:documentType:propertiesJSON:signer:maxContestFund:)`` - /// with the proposal's ``DecisionProposalDocument/propertiesJSON()``. The - /// create states the decision proposal fund on top of the fees; a proposal - /// joins no contest, so no contest fund is stated. + /// rs-sdk builds the proposal; its create states the decision proposal + /// fund (0.5 Dash), which the owner pays on top of the fees. The wallet + /// signs with the owner's first AUTHENTICATION + ECDSA key at a security + /// level the `proposal` type accepts that `signer` holds. Platform judges + /// the text, the rule and the end date; nothing is checked here. A + /// network below protocol version 15 has no decision proposals, which is + /// an error. + /// + /// Lifetime contract: as `createDocument`, the `signer` is pinned with + /// `withExtendedLifetime` across the synchronous FFI call (Rust holds a + /// `passUnretained` ctx pointer to it). /// /// - Parameters: - /// - proposal: The proposal built for `ownerIdentityId`. - /// - ownerIdentityId: The proposer, the identity the proposal was built - /// for. - /// - signer: Signs the create; see `createDocument` for its lifetime - /// contract. + /// - ownerIdentityId: The proposer's 32-byte identity id. + /// - params: The question and the rule. + /// - signer: Signs the create. + /// - Throws: `PlatformWalletError.invalidParameter` for an id that is not + /// 32 bytes or a string holding a NUL character; a refusal by Platform + /// as `PlatformWalletError.consensusRejection`, with its consensus code. public func createDecisionProposal( - _ proposal: DecisionProposalDocument, ownerIdentityId: Identifier, + params: DecisionProposalParams, signer: KeychainSigner ) async throws -> (Identifier, String) { - // `createDocument` reads exactly 32 bytes of the identifier. + // The FFI reads exactly 32 bytes of the identifier. guard ownerIdentityId.count == 32 else { throw PlatformWalletError.invalidParameter("ownerIdentityId must be exactly 32 bytes") } - let propertiesJSON = try proposal.propertiesJSON() - return try await createDocument( - ownerIdentityId: ownerIdentityId, - contractId: DecisionProposals.contractIdentifier, - documentType: DecisionProposals.documentTypeName, - propertiesJSON: propertiesJSON, - signer: signer - ) + let handle = self.handle + let signerHandle = signer.handle + let ownerBytes = [UInt8](ownerIdentityId) + return try await Task.detached(priority: .userInitiated) { + var proposalIdBytes = [UInt8](repeating: 0, count: 32) + // Receives an owned canonical-document JSON C string on success; + // freed with `platform_wallet_string_free` below. + var documentJsonPtr: UnsafeMutablePointer? = nil + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + + // Pin `signer` for the whole FFI call (see `createDocument` for + // why a bare `_ = signer` is unreliable under -O). Rust reads the + // C strings synchronously inside the call, so the + // `withDecisionProposalCString` scopes are long enough. + let result = try withExtendedLifetime(signer) { + try ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + try withDecisionProposalCString( + params.title, field: "title", invalid: PlatformWalletError.invalidParameter + ) { titlePtr in + try withDecisionProposalCString( + params.description, field: "description", + invalid: PlatformWalletError.invalidParameter + ) { descriptionPtr in + try withDecisionProposalCString( + params.url, field: "url", invalid: PlatformWalletError.invalidParameter + ) { urlPtr in + let ffiParams = DecisionProposalParamsFFI( + title: titlePtr, + description: descriptionPtr, + url: urlPtr, + pass_percent: params.passPercent, + minimum_turnout_percent: params.minimumTurnoutPercent, + end_date_ms: params.endDateMs + ) + return withUnsafePointer(to: ffiParams) { paramsPtr in + proposalIdBytes.withUnsafeMutableBufferPointer { outBp in + platform_wallet_decision_proposal_create( + handle, + ownerBp.baseAddress!, + paramsPtr, + signerHandle, + outBp.baseAddress!, + &documentJsonPtr + ) + } + } + } + } + } + } + } + + try result.check() + // On success the Rust side always writes the JSON; a null pointer + // is an FFI contract violation, not an empty proposal. + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "decision_proposal_create returned no canonical document JSON" + ) + } + return (Data(proposalIdBytes), String(cString: jsonPtr)) + }.value } - /// Replace + broadcast the decision proposal `proposalId` with the text of - /// `edited`, the stored proposal edited by - /// ``DecisionProposalDocument/edited(_:)``. Returns the proposal's 32-byte - /// id and the confirmed document's canonical JSON at its new revision. + /// Give the decision proposal `proposalId` of `ownerIdentityId` the new + /// text `edit` sets, and broadcast the replace. Returns the confirmed + /// document's canonical JSON, at its new revision, once Platform confirms + /// the transition. + /// + /// The wallet reads the proposal once, applies the edit with rs-sdk + /// (which refuses an edit that changes nothing) and puts it at the + /// revision after the one read, signed with a key chosen as + /// ``createDecisionProposal(ownerIdentityId:params:signer:)`` chooses it. + /// It never reads the proposal again before the put: if another edit + /// lands in between, Platform refuses this one instead of overwriting it + /// (consensus code 40106, `InvalidDocumentRevisionError`), and the caller + /// reads the proposal again before offering the edit anew. Platform also + /// refuses an edit more than a day after the proposal was created (or a + /// fifth of the time it runs, if that is shorter). /// - /// Routes through ``replaceDocument(ownerIdentityId:contractId:documentType:documentId:propertiesJSON:signingKeyId:signer:)`` - /// with the edited document's ``DecisionProposalDocument/propertiesJSON()``: - /// the wallet fetches the stored proposal, sets these properties and moves - /// it to the same next revision. Platform refuses the replace outside the - /// proposal's edit window. + /// Lifetime contract: as `createDocument`, the `signer` is pinned with + /// `withExtendedLifetime` across the synchronous FFI call. /// /// - Parameters: - /// - edited: The stored proposal with its new text. - /// - ownerIdentityId: The proposer. + /// - ownerIdentityId: The proposer's 32-byte identity id. /// - proposalId: The proposal's 32-byte id. - /// - signingKeyId: The owner's AUTHENTICATION + ECDSA key to sign with. - /// - signer: Signs the replace; see `replaceDocument` for its lifetime - /// contract. - public func replaceDecisionProposal( - _ edited: DecisionProposalDocument, + /// - edit: The new text. + /// - signer: Signs the replace. + /// - Throws: `PlatformWalletError.notFound` when there is no such + /// proposal; `PlatformWalletError.invalidParameter` for an id that is + /// not 32 bytes, a string holding a NUL character, a proposal of another + /// identity or an edit that changes nothing; a refusal by Platform as + /// `PlatformWalletError.consensusRejection`, with its consensus code. + public func editDecisionProposal( ownerIdentityId: Identifier, proposalId: Identifier, - signingKeyId: UInt32, + edit: DecisionProposalEdit, signer: KeychainSigner - ) async throws -> (Identifier, String) { - // `replaceDocument` reads exactly 32 bytes of each identifier. + ) async throws -> String { + // The FFI reads exactly 32 bytes of each identifier. guard ownerIdentityId.count == 32 else { throw PlatformWalletError.invalidParameter("ownerIdentityId must be exactly 32 bytes") } guard proposalId.count == 32 else { throw PlatformWalletError.invalidParameter("proposalId must be exactly 32 bytes") } - let propertiesJSON = try edited.propertiesJSON() - return try await replaceDocument( - ownerIdentityId: ownerIdentityId, - contractId: DecisionProposals.contractIdentifier, - documentType: DecisionProposals.documentTypeName, - documentId: proposalId, - propertiesJSON: propertiesJSON, - signingKeyId: signingKeyId, - signer: signer - ) + let handle = self.handle + let signerHandle = signer.handle + let ownerBytes = [UInt8](ownerIdentityId) + let proposalBytes = [UInt8](proposalId) + // `DecisionProposalEditFFI` says "remove the url" with a flag beside + // a null `url`. + let url: String? + let removeURL: Bool + switch edit.url { + case .keep: + url = nil + removeURL = false + case .set(let value): + url = value + removeURL = false + case .remove: + url = nil + removeURL = true + } + return try await Task.detached(priority: .userInitiated) { + var documentJsonPtr: UnsafeMutablePointer? = nil + defer { if let p = documentJsonPtr { platform_wallet_string_free(p) } } + + // Pin `signer` for the whole FFI call (see `createDocument`). + let result = try withExtendedLifetime(signer) { + try ownerBytes.withUnsafeBufferPointer { ownerBp -> PlatformWalletFFIResult in + try proposalBytes.withUnsafeBufferPointer { proposalBp -> PlatformWalletFFIResult in + try withDecisionProposalCString( + edit.title, field: "title", invalid: PlatformWalletError.invalidParameter + ) { titlePtr in + try withDecisionProposalCString( + edit.description, field: "description", + invalid: PlatformWalletError.invalidParameter + ) { descriptionPtr in + try withDecisionProposalCString( + url, field: "url", invalid: PlatformWalletError.invalidParameter + ) { urlPtr in + let ffiEdit = DecisionProposalEditFFI( + title: titlePtr, + description: descriptionPtr, + url: urlPtr, + remove_url: removeURL + ) + return withUnsafePointer(to: ffiEdit) { editPtr in + platform_wallet_decision_proposal_edit( + handle, + ownerBp.baseAddress!, + proposalBp.baseAddress!, + editPtr, + signerHandle, + &documentJsonPtr + ) + } + } + } + } + } + } + } + + try result.check() + guard let jsonPtr = documentJsonPtr else { + throw PlatformWalletError.walletOperation( + "decision_proposal_edit returned no canonical document JSON" + ) + } + return String(cString: jsonPtr) + }.value } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift index d5087fcaa3d..fbc68756ba7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/DecisionProposal.swift @@ -90,8 +90,9 @@ public struct DecisionProposal: Decodable, Sendable, Equatable, Hashable, Identi public var updatedAt: Date? { updatedAtMs.map(Date.init(millisecondsSince1970:)) } } -/// The question and rule of a new decision proposal -/// (`DashSDKDecisionProposalParams`). Platform judges every value when the +/// The question and rule of a new decision proposal, as +/// `ManagedPlatformWallet.createDecisionProposal` passes them on +/// (`DecisionProposalParamsFFI`). Platform judges every value when the /// proposal is created; nothing is checked here. public struct DecisionProposalParams: Sendable, Equatable { /// The question, in one line. @@ -146,10 +147,12 @@ public struct DecisionProposalParams: Sendable, Equatable { } } -/// New text for a stored decision proposal (`DashSDKDecisionProposalEdit`). -/// A field left `nil` (or `.keep`) keeps its value. rs-sdk refuses an edit -/// that changes nothing; Platform accepts an edit only within its window -/// after the proposal was created, and never a change to the rule. +/// New text for a stored decision proposal, as +/// `ManagedPlatformWallet.editDecisionProposal` passes it on +/// (`DecisionProposalEditFFI`). A field left `nil` (or `.keep`) keeps its +/// value. rs-sdk refuses an edit that changes nothing; Platform accepts an +/// edit only within its window after the proposal was created, and never a +/// change to the rule. public struct DecisionProposalEdit: Sendable, Equatable { /// What happens to the proposal's url. public enum URLChange: Sendable, Equatable { @@ -396,6 +399,37 @@ public struct YesNoVotePollState: Decodable, Sendable, Equatable { } } +// MARK: - C strings + +/// Run `body` with `string` as a NUL-terminated C string, or with `nil` when +/// `string` is `nil`. Every string a decision proposal function hands to C +/// (title, description, url, base58 ids) goes through here. +/// +/// A Swift `String` can hold U+0000, and `withCString` keeps that byte, but +/// the Rust side reads each string with `CStr::from_ptr`, which stops at the +/// first NUL: it would act on a prefix of the string passed, such as a +/// shortened title or a different `startAfter` id. A string with an embedded +/// NUL has no C representation, so it is refused with `invalid` before any +/// pointer is made. This checks only that the string can cross the +/// boundary; what a proposal may say is Platform's to judge. +/// +/// - Parameters: +/// - field: Names the string in the refusal. +/// - invalid: The invalid-parameter error of the calling API +/// (`SDKError.invalidParameter`, `PlatformWalletError.invalidParameter`). +func withDecisionProposalCString( + _ string: String?, + field: String, + invalid: (String) -> E, + _ body: (UnsafePointer?) throws -> R +) throws -> R { + guard let string else { return try body(nil) } + guard !string.utf8.contains(0) else { + throw invalid("\(field) contains a NUL character, which a C string cannot carry") + } + return try string.withCString { try body($0) } +} + // MARK: - JSON decoding /// Decodes the JSON the decision proposal FFI functions return. Shared by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift index 7552fec09f2..11ae35fc1e7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Voting/SDK+DecisionProposals.swift @@ -5,105 +5,19 @@ import DashSDKFFI // // Thin bridges over the `dash_sdk_decision_proposal_*` functions of // `rs-sdk-ffi`, which call `dash_sdk::platform::decision_proposals` and -// `dash_sdk::platform::yes_no_vote_polls`. rs-sdk decides everything: the -// proposal's properties, the fund the create states, which documents are -// proposals, refusing an edit that changes nothing, the next revision, the -// poll's id. These wrappers marshal arguments in and decode the JSON out. +// `dash_sdk::platform::yes_no_vote_polls`. rs-sdk decides everything: which +// documents are proposals, the poll's id, the vote. These wrappers marshal +// arguments in and decode the JSON out. Every string goes to C through +// ``withDecisionProposalCString(_:field:invalid:_:)``, which refuses one +// with an embedded NUL. // // Every read and the vote park their thread inside Rust until DAPI answers, // so they run on the shared Platform query queue // (``SDK/performBlockingQuery(_:)``) and the caller only awaits. // -// Creating and editing a proposal goes through the platform wallet, the same -// path the app creates and replaces every other document by: -// ``buildDecisionProposal(ownerIdentityId:params:)`` (or -// ``fetchDecisionProposalDocument(proposalId:)`` then -// ``DecisionProposalDocument/edited(_:)``) here, then -// `ManagedPlatformWallet.createDecisionProposal` / -// `ManagedPlatformWallet.replaceDecisionProposal`. - -/// A decision proposal document held by `rs-sdk-ffi`: one built for a create -/// (``SDK/buildDecisionProposal(ownerIdentityId:params:)``), a stored one -/// fetched for an edit (``SDK/fetchDecisionProposalDocument(proposalId:)``), -/// or an edited copy (``edited(_:)``). -/// -/// Owns its `DocumentHandle` and frees it on deinit. Both methods only read -/// the document, and it never changes after it is made, so the object may be -/// shared across threads. -public final class DecisionProposalDocument: @unchecked Sendable { - /// The `DocumentHandle *` this object owns. - private let handle: OpaquePointer - - /// Takes ownership of `handle`. - fileprivate init(handle: OpaquePointer) { - self.handle = handle - } - - deinit { - dash_sdk_document_free(handle) - } - - /// The document's own properties as the JSON object the platform - /// wallet's document create and replace take - /// (`dash_sdk_decision_proposal_properties_json`). A proposal without a - /// url has no `url` key. - public func propertiesJSON() throws -> String { - let result = dash_sdk_decision_proposal_properties_json(handle) - guard let json = try DecisionProposalFFI.consumeString(result) else { - throw SDKError.internalError("dash_sdk_decision_proposal_properties_json returned no data") - } - return json - } - - /// A copy of this proposal with the text `edit` sets, at its next revision - /// (`dash_sdk_decision_proposal_build_edit`). Pure: no network, and this - /// document is left as it is. - /// - /// rs-sdk refuses, as ``SDKError/invalidParameter(_:)``, an edit that - /// changes nothing and a document that is not a decision proposal. - public func edited(_ edit: DecisionProposalEdit) throws -> DecisionProposalDocument { - let url: String? - let removeURL: Bool - switch edit.url { - case .keep: - url = nil - removeURL = false - case .set(let value): - url = value - removeURL = false - case .remove: - url = nil - removeURL = true - } - - let result = withDecisionProposalCString(edit.title) { titlePtr in - withDecisionProposalCString(edit.description) { descriptionPtr in - withDecisionProposalCString(url) { urlPtr in - let ffiEdit = DashSDKDecisionProposalEdit( - title: titlePtr, - description: descriptionPtr, - url: urlPtr, - remove_url: removeURL - ) - return withUnsafePointer(to: ffiEdit) { editPtr in - // The contract id and type name are the FFI's own - // static strings, passed straight back. - dash_sdk_decision_proposal_build_edit( - handle, - dash_sdk_decision_proposal_contract_id(), - dash_sdk_decision_proposal_document_type_name(), - editPtr - ) - } - } - } - } - guard let edited = try DecisionProposalFFI.consumeDocument(result) else { - throw SDKError.internalError("dash_sdk_decision_proposal_build_edit returned no document") - } - return edited - } -} +// Creating and editing a proposal are platform wallet operations: +// `ManagedPlatformWallet.createDecisionProposal` and +// `ManagedPlatformWallet.editDecisionProposal`. extension SDK { @@ -115,7 +29,9 @@ extension SDK { /// - Parameter id: The proposal's id, base58. public nonisolated func decisionProposal(id: String) async throws -> DecisionProposal? { try await performBlockingQuery { handle in - let result = id.withCString { idPtr in + let result = try withDecisionProposalCString( + id, field: "id", invalid: SDKError.invalidParameter + ) { idPtr in dash_sdk_decision_proposal_fetch(handle, idPtr) } return try DecisionProposalFFI.consumeString(result).map { @@ -145,7 +61,9 @@ extension SDK { let fromMs = from?.millisecondsSince1970 ?? 0 let toMs = to?.millisecondsSince1970 ?? 0 return try await performBlockingQuery { handle in - let result = withDecisionProposalCString(startAfter) { startAfterPtr in + let result = try withDecisionProposalCString( + startAfter, field: "startAfter", invalid: SDKError.invalidParameter + ) { startAfterPtr in dash_sdk_decision_proposal_list_by_end_date( handle, fromMs, toMs, limit ?? 0, startAfterPtr) } @@ -168,8 +86,12 @@ extension SDK { startAfter: String? = nil ) async throws -> [DecisionProposal] { try await performBlockingQuery { handle in - let result = ownerId.withCString { ownerPtr in - withDecisionProposalCString(startAfter) { startAfterPtr in + let result = try withDecisionProposalCString( + ownerId, field: "ownerId", invalid: SDKError.invalidParameter + ) { ownerPtr in + try withDecisionProposalCString( + startAfter, field: "startAfter", invalid: SDKError.invalidParameter + ) { startAfterPtr in dash_sdk_decision_proposal_list_by_owner( handle, ownerPtr, limit ?? 0, startAfterPtr) } @@ -191,7 +113,9 @@ extension SDK { startAfter: String? = nil ) async throws -> [DecisionProposal] { try await performBlockingQuery { handle in - let result = withDecisionProposalCString(startAfter) { startAfterPtr in + let result = try withDecisionProposalCString( + startAfter, field: "startAfter", invalid: SDKError.invalidParameter + ) { startAfterPtr in dash_sdk_decision_proposal_list_by_created_at(handle, limit ?? 0, startAfterPtr) } return try DecisionProposalFFI.consumeListing( @@ -206,7 +130,9 @@ extension SDK { /// - Parameter proposalId: The proposal's id, base58. public nonisolated func decisionProposalVotePollId(proposalId: String) async throws -> String { try await performBlockingQuery { handle in - let result = proposalId.withCString { idPtr in + let result = try withDecisionProposalCString( + proposalId, field: "proposalId", invalid: SDKError.invalidParameter + ) { idPtr in dash_sdk_decision_proposal_get_vote_poll_id(handle, idPtr) } guard let pollId = try DecisionProposalFFI.consumeString(result) else { @@ -225,7 +151,9 @@ extension SDK { proposalId: String ) async throws -> YesNoVotePollState? { try await performBlockingQuery { handle in - let result = proposalId.withCString { idPtr in + let result = try withDecisionProposalCString( + proposalId, field: "proposalId", invalid: SDKError.invalidParameter + ) { idPtr in dash_sdk_decision_proposal_get_vote_poll_state(handle, idPtr) } return try DecisionProposalFFI.consumeString(result).map { @@ -255,8 +183,10 @@ extension SDK { throw SDKError.invalidParameter("proTxHash must be exactly 32 bytes") } return try await performBlockingQuery { handle in - let result = proTxHash.withUnsafeBytes { proTxPtr in - proposalId.withCString { idPtr in + let result = try proTxHash.withUnsafeBytes { proTxPtr in + try withDecisionProposalCString( + proposalId, field: "proposalId", invalid: SDKError.invalidParameter + ) { idPtr in dash_sdk_decision_proposal_get_masternode_vote( handle, proTxPtr.bindMemory(to: UInt8.self).baseAddress, @@ -306,9 +236,11 @@ extension SDK { let networkValue = network.ffiValue try await performBlockingQuery { handle in - let result = proTxHash.withUnsafeBytes { proTxPtr in - votingPrivateKey.withUnsafeBytes { keyPtr in - proposalId.withCString { idPtr in + let result = try proTxHash.withUnsafeBytes { proTxPtr in + try votingPrivateKey.withUnsafeBytes { keyPtr in + try withDecisionProposalCString( + proposalId, field: "proposalId", invalid: SDKError.invalidParameter + ) { idPtr in dash_sdk_decision_proposal_cast_vote( handle, idPtr, @@ -324,76 +256,18 @@ extension SDK { _ = try DecisionProposalFFI.consumeString(result) } } - - // MARK: - Create and edit (documents for the platform wallet) - - /// Build a decision proposal of `ownerIdentityId` asking what `params` - /// says (`dash_sdk_decision_proposal_build`). Nothing is broadcast. - /// - /// Create it with `ManagedPlatformWallet.createDecisionProposal`, which - /// passes its ``DecisionProposalDocument/propertiesJSON()`` to the wallet's - /// document create. The create states the decision proposal fund on top - /// of the fees. Platform judges the text, the rule and the end date then; - /// nothing is checked before. A network below protocol version 15 has no - /// decision proposals contract, which is an error. - /// - /// - Parameter ownerIdentityId: The proposer's identity id, base58. - public nonisolated func buildDecisionProposal( - ownerIdentityId: String, - params: DecisionProposalParams - ) async throws -> DecisionProposalDocument { - try await performBlockingQuery { handle in - let result = ownerIdentityId.withCString { ownerPtr in - params.title.withCString { titlePtr in - params.description.withCString { descriptionPtr in - withDecisionProposalCString(params.url) { urlPtr in - let ffiParams = DashSDKDecisionProposalParams( - title: titlePtr, - description: descriptionPtr, - url: urlPtr, - pass_percent: params.passPercent, - minimum_turnout_percent: params.minimumTurnoutPercent, - end_date_ms: params.endDateMs - ) - return withUnsafePointer(to: ffiParams) { paramsPtr in - dash_sdk_decision_proposal_build(handle, ownerPtr, paramsPtr) - } - } - } - } - } - return try DecisionProposalFFI.consumeBuildResult(result) - } - } - - /// The stored decision proposal `proposalId` names, as a document to edit - /// with ``DecisionProposalDocument/edited(_:)``, or `nil` when there is - /// none (`dash_sdk_decision_proposal_fetch_document`). - /// - /// - Parameter proposalId: The proposal's id, base58. - public nonisolated func fetchDecisionProposalDocument( - proposalId: String - ) async throws -> DecisionProposalDocument? { - try await performBlockingQuery { handle in - let result = proposalId.withCString { idPtr in - dash_sdk_decision_proposal_fetch_document(handle, idPtr) - } - return try DecisionProposalFFI.consumeDocument(result) - } - } } // MARK: - FFI result handling /// Takes ownership of the `DashSDKResult`s the decision proposal functions -/// return: every owned string, document handle and error is freed on every -/// path, so only Swift values (or a ``DecisionProposalDocument`` that owns -/// its handle) leave these functions. +/// return: every owned string and error is freed on every path, so only +/// Swift values leave these functions. private enum DecisionProposalFFI { /// Throw the typed FFI error, freeing it. static func throwIfFailed(_ result: DashSDKResult) throws { guard let error = result.error else { return } - // Keep the FFI error code, so a refused vote or create surfaces as a + // Keep the FFI error code, so a refused vote surfaces as a // consensus rejection and a transport failure as `.networkError` or // `.timeout`. let sdkError = SDKError.fromDashSDKError(error.pointee) @@ -418,38 +292,4 @@ private enum DecisionProposalFFI { } return try DecisionProposalJSON.decode([DecisionProposal].self, from: json) } - - /// The document handle the result carries, now owned by the returned - /// object; `nil` for no data. - static func consumeDocument(_ result: DashSDKResult) throws -> DecisionProposalDocument? { - try throwIfFailed(result) - guard let data = result.data else { return nil } - return DecisionProposalDocument(handle: OpaquePointer(data)) - } - - /// The document of a `DashSDKDocumentCreateResult`. The struct is freed; - /// its document handle stays alive and is owned by the returned object. - /// The entropy is not kept: the platform wallet's create draws its own. - static func consumeBuildResult(_ result: DashSDKResult) throws -> DecisionProposalDocument { - try throwIfFailed(result) - guard let data = result.data else { - throw SDKError.internalError("dash_sdk_decision_proposal_build returned no data") - } - let createResult = data.assumingMemoryBound(to: DashSDKDocumentCreateResult.self) - let documentHandle = createResult.pointee.document_handle - dash_sdk_document_create_result_free(createResult) - guard let documentHandle else { - throw SDKError.internalError("dash_sdk_decision_proposal_build returned no document handle") - } - return DecisionProposalDocument(handle: documentHandle) - } -} - -/// Run `body` with `string` as a C string, or with `nil` when it is `nil`. -private func withDecisionProposalCString( - _ string: String?, - _ body: (UnsafePointer?) throws -> R -) rethrows -> R { - guard let string else { return try body(nil) } - return try string.withCString { try body($0) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift index d85bbf88033..9062c5e6608 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalDetailView.swift @@ -82,12 +82,13 @@ struct DecisionProposalDetailView: View { } .environmentObject(appState) } - .sheet(isPresented: $showEditSheet) { + // Read the proposal again however the sheet closes: a refused edit + // may have found it changed, and the form then shows the newer text. + .sheet(isPresented: $showEditSheet, onDismiss: { Task { await refresh() } }) { if let owner = localOwner { EditDecisionProposalView(proposal: proposal, owner: owner) { message in actionMessage = message actionMessageIsError = false - Task { await refresh() } } .environmentObject(appState) .environmentObject(walletManager) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift index 1de6f7aede8..18464d17bd7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift @@ -6,12 +6,10 @@ import SwiftDashSDK /// Files a new decision proposal for an identity held by a loaded wallet. /// -/// rs-sdk builds the proposal (`SDK.buildDecisionProposal`) and the platform -/// wallet creates it (`ManagedPlatformWallet.createDecisionProposal`), the -/// same document-create path `CreateDocumentView` takes, so the signing key -/// is chosen and used on the Rust side through the wallet's -/// `KeychainSigner`. Platform judges the text, the rule and the end date; -/// this form only collects them. +/// The platform wallet creates it (`ManagedPlatformWallet.createDecisionProposal`): +/// rs-sdk builds the proposal and the wallet chooses and uses the signing key +/// on the Rust side through the `KeychainSigner`. Platform judges the text, +/// the rule and the end date; this form only collects them. struct CreateDecisionProposalView: View { /// Called with the new proposal's id (base58) once Platform confirmed it. let onCreated: (String) -> Void @@ -169,7 +167,7 @@ struct CreateDecisionProposalView: View { } .buttonStyle(.borderedProminent) .accessibilityIdentifier("createDecisionProposal.submitButton") - .disabled(isSubmitting || appState.sdk == nil || managedWallet == nil) + .disabled(isSubmitting || managedWallet == nil) } } @@ -203,10 +201,6 @@ struct CreateDecisionProposalView: View { // MARK: - Submit private func submit() { - guard let sdk = appState.sdk else { - submitError = "SDK not initialized" - return - } guard let owner = selectedOwner, let wallet = managedWallet else { submitError = DocumentActionError.noWallet.localizedDescription return @@ -221,7 +215,6 @@ struct CreateDecisionProposalView: View { minimumTurnoutPercent: UInt8(clamping: minimumTurnoutPercent), endDate: endDate ) - let ownerIdBase58 = owner.identityIdBase58 let ownerId = owner.identityId // Fresh `KeychainSigner` per submit, as `CreateDocumentView` does. let signer = KeychainSigner(modelContainer: modelContext.container) @@ -229,13 +222,9 @@ struct CreateDecisionProposalView: View { isSubmitting = true Task { do { - let proposal = try await sdk.buildDecisionProposal( - ownerIdentityId: ownerIdBase58, - params: params - ) let (proposalId, _) = try await wallet.createDecisionProposal( - proposal, ownerIdentityId: ownerId, + params: params, signer: signer ) let proposalIdBase58 = proposalId.toBase58String() @@ -253,14 +242,17 @@ struct CreateDecisionProposalView: View { /// Gives a proposal a local identity filed new text. /// -/// rs-sdk reads the stored proposal and applies the edit -/// (`SDK.fetchDecisionProposalDocument`, `DecisionProposalDocument.edited`), -/// refusing one that changes nothing; the platform wallet replaces it -/// (`ManagedPlatformWallet.replaceDecisionProposal`), signing with the key -/// `DocumentActionRunner.resolveSigning` picks, as `ReplaceDocumentView` -/// does. Platform refuses the replace outside the edit window. +/// The platform wallet edits it (`ManagedPlatformWallet.editDecisionProposal`): +/// it reads the proposal, rs-sdk applies the edit (refusing one that changes +/// nothing) and the wallet chooses the signing key and puts the proposal at +/// the revision after the one it read. Platform refuses the edit outside the +/// edit window, and refuses it when another edit landed after that read. +/// +/// After any failure the form reads the proposal again. When Platform's copy +/// differs from the one the form was opened on (another edit landed), the +/// form shows that text in place of the fields, so the next try starts from +/// it rather than from text Platform no longer has. struct EditDecisionProposalView: View { - let proposal: DecisionProposal let owner: PersistentIdentity /// Called with a summary once Platform confirmed the edit. let onEdited: (String) -> Void @@ -270,6 +262,8 @@ struct EditDecisionProposalView: View { @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss + /// The proposal as last read, which the fields were filled from. + @State private var proposal: DecisionProposal @State private var title: String @State private var description: String @State private var url: String @@ -277,14 +271,19 @@ struct EditDecisionProposalView: View { @State private var submitError: String? init(proposal: DecisionProposal, owner: PersistentIdentity, onEdited: @escaping (String) -> Void) { - self.proposal = proposal self.owner = owner self.onEdited = onEdited + _proposal = State(initialValue: proposal) _title = State(initialValue: proposal.title) _description = State(initialValue: proposal.description) _url = State(initialValue: proposal.url ?? "") } + private var managedWallet: ManagedPlatformWallet? { + guard let walletId = owner.wallet?.walletId else { return nil } + return walletManager.wallet(for: walletId) + } + var body: some View { NavigationStack { Form { @@ -323,7 +322,7 @@ struct EditDecisionProposalView: View { } .buttonStyle(.borderedProminent) .accessibilityIdentifier("editDecisionProposal.submitButton") - .disabled(isSubmitting || appState.sdk == nil) + .disabled(isSubmitting || managedWallet == nil) } } .navigationTitle("Edit Proposal") @@ -350,28 +349,14 @@ struct EditDecisionProposalView: View { } private func submit() { - guard let sdk = appState.sdk else { - submitError = "SDK not initialized" + guard let wallet = managedWallet else { + submitError = DocumentActionError.noWallet.localizedDescription return } guard let proposalId = Data.identifier(fromBase58: proposal.id), proposalId.count == 32 else { submitError = "The proposal id is not a 32-byte base58 identifier." return } - let wallet: ManagedPlatformWallet - let signingKeyId: UInt32 - do { - // The proposal type states no signature security level, so the - // document type is left out and the HIGH default applies. - (wallet, signingKeyId) = try DocumentActionRunner.resolveSigning( - for: owner, - documentType: nil, - walletManager: walletManager - ) - } catch { - submitError = error.localizedDescription - return - } // An emptied url field removes the url; an untouched empty one keeps // having none. @@ -384,32 +369,49 @@ struct EditDecisionProposalView: View { } let edit = DecisionProposalEdit(title: title, description: description, url: urlChange) let ownerId = owner.identityId - let proposalIdBase58 = proposal.id let signer = KeychainSigner(modelContainer: modelContext.container) isSubmitting = true Task { do { - guard let stored = try await sdk.fetchDecisionProposalDocument( - proposalId: proposalIdBase58 - ) else { - throw SDKError.notFound("Platform has no proposal \(proposalIdBase58)") - } - let edited = try stored.edited(edit) - _ = try await wallet.replaceDecisionProposal( - edited, + _ = try await wallet.editDecisionProposal( ownerIdentityId: ownerId, proposalId: proposalId, - signingKeyId: signingKeyId, + edit: edit, signer: signer ) onEdited("Edit accepted by Platform.") isSubmitting = false dismiss() } catch { - submitError = error.localizedDescription + let reloadNote = await reloadAfterFailure() + submitError = [error.localizedDescription, reloadNote] + .compactMap { $0 } + .joined(separator: "\n\n") isSubmitting = false } } } + + /// Read the proposal again after a failed edit. When Platform's copy + /// differs from the one the fields were filled from, take it and refill + /// the fields. Returns what the person should know about the reload, or + /// `nil` when there is nothing to add. + private func reloadAfterFailure() async -> String? { + guard let sdk = appState.sdk else { return nil } + do { + guard let current = try await sdk.decisionProposal(id: proposal.id) else { + return "Platform no longer has this proposal." + } + guard current != proposal else { return nil } + proposal = current + title = current.title + description = current.description + url = current.url ?? "" + let revision = current.revision.map { " (revision \($0))" } ?? "" + return "Platform's copy of the proposal changed since this form was opened; the form now shows its current text\(revision)." + } catch { + return "The proposal could not be read again: \(error.localizedDescription)" + } + } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalCStringTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalCStringTests.swift new file mode 100644 index 00000000000..454ce105e24 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DecisionProposalCStringTests.swift @@ -0,0 +1,99 @@ +import Foundation +import XCTest +@testable import SwiftDashSDK + +/// Hermetic tests for `withDecisionProposalCString`, the Swift-to-C +/// conversion every decision proposal string (title, description, url, +/// base58 ids) goes through. The Rust side reads each one with +/// `CStr::from_ptr`, which stops at the first NUL, so a string holding U+0000 +/// must be refused rather than handed over cut short. No FFI call is made. +final class DecisionProposalCStringTests: XCTestCase { + private struct BodyError: Error, Equatable {} + + /// The bytes `pointer` holds up to its NUL terminator. + private func cStringBytes(_ pointer: UnsafePointer?) -> [UInt8]? { + guard let pointer else { return nil } + return Array(UnsafeBufferPointer(start: pointer, count: strlen(pointer))) + .map { UInt8(bitPattern: $0) } + } + + func testShouldHandOverAStringWithoutANULWhole() throws { + let title = "Raise the block size? Ja, 是, oui ✓" + let bytes = try withDecisionProposalCString( + title, field: "title", invalid: SDKError.invalidParameter + ) { pointer in + cStringBytes(pointer) + } + XCTAssertEqual(bytes, Array(title.utf8)) + } + + func testShouldHandOverAnEmptyString() throws { + // Empty text is representable; whether a proposal may have it is + // Platform's call. + let bytes = try withDecisionProposalCString( + "", field: "description", invalid: SDKError.invalidParameter + ) { pointer in + cStringBytes(pointer) + } + XCTAssertEqual(bytes, []) + } + + func testShouldHandOverNilAsANullPointer() throws { + let isNull = try withDecisionProposalCString( + nil, field: "url", invalid: SDKError.invalidParameter + ) { pointer in + pointer == nil + } + XCTAssertTrue(isNull) + } + + func testShouldRefuseAnEmbeddedNULBeforeMakingAPointer() { + for value in ["Raise\0 the block size?", "\0Raise", "Raise\0", "\0"] { + var bodyRan = false + XCTAssertThrowsError( + try withDecisionProposalCString( + value, field: "title", invalid: SDKError.invalidParameter + ) { _ in + bodyRan = true + }, + "\(value.debugDescription) must be refused" + ) { error in + guard case SDKError.invalidParameter(let message) = error else { + return XCTFail("expected SDKError.invalidParameter, got \(error)") + } + XCTAssertTrue(message.hasPrefix("title "), message) + XCTAssertTrue(message.contains("NUL"), message) + } + XCTAssertFalse(bodyRan, "\(value.debugDescription) must not reach C") + } + } + + func testShouldRefuseWithTheCallingAPIsInvalidParameterError() { + // The platform wallet's create and edit refuse with their own error + // type, naming the field. + XCTAssertThrowsError( + try withDecisionProposalCString( + "4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF\0junk", + field: "startAfter", + invalid: PlatformWalletError.invalidParameter + ) { _ in } + ) { error in + guard case PlatformWalletError.invalidParameter(let message) = error else { + return XCTFail("expected PlatformWalletError.invalidParameter, got \(error)") + } + XCTAssertTrue(message.hasPrefix("startAfter "), message) + } + } + + func testShouldPassTheBodysErrorThroughUnchanged() { + XCTAssertThrowsError( + try withDecisionProposalCString( + "Raise the block size?", field: "title", invalid: SDKError.invalidParameter + ) { _ in + throw BodyError() + } + ) { error in + XCTAssertEqual(error as? BodyError, BodyError()) + } + } +} From 62ebcabbbb319af59eaded416fbef60a9cb4432c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 16:49:43 +0300 Subject: [PATCH 4/5] fix(sdk): proposal edit forms send only the fields the user changed The Swift edit form and the Kotlin edit dialog sent every field, so a field the user left alone wrote back the text the form was opened with, over a newer edit by another client made before the wallet's read. Each now sends only the title, description and url that differ from the proposal it shows; an emptied url field removes the url. Co-Authored-By: Claude Opus 5.5 --- .../identity/DecisionProposalDetailScreen.kt | 16 +++++++++------- .../DecisionProposalFormViews.swift | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt index c130112b7e8..6d0b365caab 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt @@ -669,17 +669,19 @@ private fun EditDecisionProposalDialog( error = "The proposal id is not a 32-byte base58 identifier." return@TextButton } - // An emptied url field removes the url; an untouched empty one - // keeps having none. + // Only the fields the user changed from the proposal this dialog + // shows are sent: an untouched field must not write back the text + // it was opened with over a newer edit by another client. An + // emptied url field removes the url. val trimmedUrl = url.trim() val urlChange = when { - trimmedUrl.isNotEmpty() -> DecisionProposalEdit.UrlChange.Set(trimmedUrl) - proposal.url == null -> DecisionProposalEdit.UrlChange.Keep - else -> DecisionProposalEdit.UrlChange.Remove + trimmedUrl == proposal.url.orEmpty() -> DecisionProposalEdit.UrlChange.Keep + trimmedUrl.isEmpty() -> DecisionProposalEdit.UrlChange.Remove + else -> DecisionProposalEdit.UrlChange.Set(trimmedUrl) } val edit = DecisionProposalEdit( - title = title, - description = description, + title = title.takeIf { it != proposal.title }, + description = description.takeIf { it != proposal.description }, url = urlChange, ) isSubmitting = true diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift index 18464d17bd7..ba88fc7def3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DecisionProposals/DecisionProposalFormViews.swift @@ -358,16 +358,24 @@ struct EditDecisionProposalView: View { return } - // An emptied url field removes the url; an untouched empty one keeps - // having none. + // Only the fields the user changed from the proposal this form shows + // are sent: an untouched field must not write back the text it was + // opened with over a newer edit by another client. An emptied url + // field removes the url. let trimmedURL = url.trimmingCharacters(in: .whitespacesAndNewlines) let urlChange: DecisionProposalEdit.URLChange - if trimmedURL.isEmpty { - urlChange = proposal.url == nil ? .keep : .remove + if trimmedURL == (proposal.url ?? "") { + urlChange = .keep + } else if trimmedURL.isEmpty { + urlChange = .remove } else { urlChange = .set(trimmedURL) } - let edit = DecisionProposalEdit(title: title, description: description, url: urlChange) + let edit = DecisionProposalEdit( + title: title == proposal.title ? nil : title, + description: description == proposal.description ? nil : description, + url: urlChange + ) let ownerId = owner.identityId let signer = KeychainSigner(modelContainer: modelContext.container) From 4e396f541b6a0a02564a4e0f88d571cc0e183c44 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 17:39:10 +0300 Subject: [PATCH 5/5] fix(sdk): masternode vote and its voting key diagnosis live in rs-sdk Review fixes: - rs-sdk put_masternode_vote signs a vote with the caller's signer, broadcasts it, and only when Platform refuses the voting key fetches the voter identity to say why, returning MasternodeVoteFailure. The FFI's MasternodeVoter (shared by the contest and decision proposal votes) now adapts the raw key and words the failure; it no longer sequences network calls. - KotlinExampleApp disables the proposal vote button once the poll has finished, as the Swift app does. Co-Authored-By: Claude Opus 5.5 --- .../identity/DecisionProposalDetailScreen.kt | 3 +- .../rs-sdk-ffi/src/voting/masternode_vote.rs | 67 ++++++------------- .../transition/masternode_vote_keys.rs | 60 ++++++++++++++++- 3 files changed, 82 insertions(+), 48 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt index 6d0b365caab..c76d1737cdd 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/DecisionProposalDetailScreen.kt @@ -191,7 +191,8 @@ fun DecisionProposalDetailScreen( SubmitButton( text = "Vote as a Masternode…", isLoading = false, - enabled = sdk != null && shown != null, + // Platform takes votes only while the poll is open, as on iOS + enabled = sdk != null && shown != null && pollState?.result == null, modifier = Modifier.fillMaxWidth().testTag("decisionProposal.vote"), ) { actionMessage = null diff --git a/packages/rs-sdk-ffi/src/voting/masternode_vote.rs b/packages/rs-sdk-ffi/src/voting/masternode_vote.rs index a7c633f1272..47e6af72ecf 100644 --- a/packages/rs-sdk-ffi/src/voting/masternode_vote.rs +++ b/packages/rs-sdk-ffi/src/voting/masternode_vote.rs @@ -18,7 +18,7 @@ //! `hash160(pubkey)`, the voting address, which names both the voter identity and the key on //! it. //! -//! The voting key is built locally, not fetched ([`voter_identity_voting_key`]): Platform +//! The voting key is built locally, not fetched (rs-sdk's `voter_identity_voting_key`): Platform //! always gives a voter identity's voting key id 0, and a rotation creates a different //! identity (the identifier includes the voting address) whose key is also 0. //! `SingleKeySigner::can_sign_with` recomputes the same `hash160(pubkey)` from the private @@ -54,14 +54,12 @@ use crate::types::{FFINetwork, Network}; use crate::FFIError; use dash_sdk::dpp::dashcore::hashes::{hash160, Hash}; use dash_sdk::dpp::dashcore::ProTxHash; -use dash_sdk::dpp::identity::IdentityPublicKey; use dash_sdk::dpp::platform_value::Identifier; use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; use dash_sdk::dpp::voting::votes::Vote; use dash_sdk::platform::transition::masternode_vote_keys::{ - diagnose_voting_key_failure, is_voting_key_failure, voter_identity_voting_key, VotingKeyProblem, + put_masternode_vote, MasternodeVoteFailure, VotingKeyProblem, }; -use dash_sdk::platform::transition::vote::PutVote; use dash_sdk::Sdk; use simple_signer::SingleKeySigner; use zeroize::Zeroizing; @@ -75,8 +73,6 @@ pub(crate) struct MasternodeVoter { voting_address: [u8; 20], /// Signs with the voting private key. signer: SingleKeySigner, - /// The `ECDSA_HASH160` voting key Platform holds for the voter identity. - voting_key: IdentityPublicKey, } impl MasternodeVoter { @@ -112,50 +108,27 @@ impl MasternodeVoter { pro_tx_hash, voting_address, signer, - voting_key: voter_identity_voting_key(&voting_address), }) } - /// The pro_tx_hash as Platform names the masternode. - pub(crate) fn pro_tx_hash(&self) -> Identifier { - Identifier::new(self.pro_tx_hash) - } - - /// Signs `vote` with the voting key, broadcasts it and waits for the response. When - /// Platform refused the voting key, the voter identity is fetched to say why. + /// Signs `vote` with the voting key, broadcasts it and waits for the response, through + /// rs-sdk's [`put_masternode_vote`], which explains a refused voting key. This only words + /// the failure for the host. pub(crate) async fn put_vote(&self, sdk: &Sdk, vote: &Vote) -> Result<(), FFIError> { - let broadcast = vote - .put_to_platform_and_wait_for_response( - self.pro_tx_hash(), - &self.voting_key, - sdk, - &self.signer, - None, - ) - .await; - let Err(broadcast_error) = broadcast else { - return Ok(()); - }; - - // Only a signature failure about the voting key is worth explaining. A closed poll, a - // fee failure or a transport error says nothing about the identity, and diagnosing - // those would let an absent voter identity pass for their cause. - if !is_voting_key_failure(&broadcast_error) { - return Err(FFIError::from(broadcast_error)); - } - - let problem = diagnose_voting_key_failure( + put_masternode_vote( sdk, + vote, ProTxHash::from_byte_array(self.pro_tx_hash), &self.voting_address, + &self.signer, + None, ) - .await; - - // When the identity and its key both check out, the signature failure was about - // something else on the key path: keep the original error. - Err(problem - .map(describe_voting_key_problem) - .unwrap_or_else(|| FFIError::from(broadcast_error))) + .await + .map(|_| ()) + .map_err(|failure| match failure { + MasternodeVoteFailure::VotingKey(problem) => describe_voting_key_problem(problem), + MasternodeVoteFailure::Broadcast(error) => FFIError::from(*error), + }) } } @@ -212,6 +185,7 @@ mod tests { use super::*; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::identity::signer::Signer; + use dash_sdk::platform::transition::masternode_vote_keys::voter_identity_voting_key; #[test] fn should_reverse_the_wire_order_pro_tx_hash() { @@ -265,12 +239,13 @@ mod tests { MasternodeVoter::from_raw(pro_tx_hash.as_ptr(), key.as_ptr(), FFINetwork::Testnet) } .expect("a valid key"); - assert_eq!(voter.voting_key.id(), 0); - assert_eq!(voter.voting_key.data().as_slice(), &voter.voting_address); - assert!(voter.signer.can_sign_with(&voter.voting_key)); + let voting_key = voter_identity_voting_key(&voter.voting_address); + assert_eq!(voting_key.id(), 0); + assert_eq!(voting_key.data().as_slice(), &voter.voting_address); + assert!(voter.signer.can_sign_with(&voting_key)); let mut reversed = pro_tx_hash; reversed.reverse(); - assert_eq!(voter.pro_tx_hash(), Identifier::new(reversed)); + assert_eq!(voter.pro_tx_hash, reversed); } // ---- describe_voting_key_problem ---------------------------------------- diff --git a/packages/rs-sdk/src/platform/transition/masternode_vote_keys.rs b/packages/rs-sdk/src/platform/transition/masternode_vote_keys.rs index c2348b4e5ed..13d5a0a82c5 100644 --- a/packages/rs-sdk/src/platform/transition/masternode_vote_keys.rs +++ b/packages/rs-sdk/src/platform/transition/masternode_vote_keys.rs @@ -10,7 +10,9 @@ //! //! Signing stays with the caller: producing a [`Signer`] from a raw key is a //! binding concern, and pulling `simple-signer` into the public SDK to do it -//! here would be the wrong trade. +//! here would be the wrong trade. [`put_masternode_vote`] takes the caller's +//! signer and owns the rest: the broadcast, and the diagnosis of a refused +//! voting key. //! //! [`PutVote`]: super::vote::PutVote //! [`Signer`]: dpp::identity::signer::Signer @@ -24,6 +26,11 @@ use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Identifier}; +use dpp::identity::signer::Signer; +use dpp::voting::votes::Vote; + +use crate::platform::transition::put_settings::PutSettings; +use crate::platform::transition::vote::PutVote; use crate::platform::Fetch; use crate::{Error, Sdk}; @@ -176,6 +183,57 @@ pub async fn diagnose_voting_key_failure( } } +/// Why a masternode vote was not recorded. +#[derive(Debug)] +pub enum MasternodeVoteFailure { + /// Platform refused the voting key, and the voter identity says why. + VotingKey(VotingKeyProblem), + /// Any other failure, or a refused voting key the voter identity does not + /// explain. + Broadcast(Box), +} + +/// Signs `vote` for the masternode `pro_tx_hash` with the voting key of +/// `voting_address` that `signer` holds, broadcasts it, and waits for the vote +/// Platform recorded. +/// +/// The voting key is the one Platform gives every voter identity +/// ([`voter_identity_voting_key`]), so nothing is fetched before the +/// broadcast. Only when Platform refuses that key +/// ([`is_voting_key_failure`]) is the voter identity fetched to say why +/// ([`diagnose_voting_key_failure`]); every other failure, and a refusal the +/// identity does not explain, comes back as it was. +pub async fn put_masternode_vote>( + sdk: &Sdk, + vote: &Vote, + pro_tx_hash: ProTxHash, + voting_address: &[u8; 20], + signer: &S, + settings: Option, +) -> Result { + let voting_key = voter_identity_voting_key(voting_address); + let broadcast_error = match vote + .put_to_platform_and_wait_for_response( + Identifier::new(pro_tx_hash.to_byte_array()), + &voting_key, + sdk, + signer, + settings, + ) + .await + { + Ok(recorded) => return Ok(recorded), + Err(error) => error, + }; + if !is_voting_key_failure(&broadcast_error) { + return Err(MasternodeVoteFailure::Broadcast(Box::new(broadcast_error))); + } + match diagnose_voting_key_failure(sdk, pro_tx_hash, voting_address).await { + Some(problem) => Err(MasternodeVoteFailure::VotingKey(problem)), + None => Err(MasternodeVoteFailure::Broadcast(Box::new(broadcast_error))), + } +} + #[cfg(test)] mod tests { use super::*;