Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.dashfoundation.example.ui.contracts

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand Down Expand Up @@ -48,6 +49,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity
import org.dashfoundation.dashsdk.queries.PropertyConstraintViolation
import org.dashfoundation.example.di.LocalAppContainer
import org.dashfoundation.example.di.LocalAppState
import org.dashfoundation.example.ui.components.AccessiblePicker
Expand Down Expand Up @@ -88,6 +90,13 @@ import org.dashfoundation.example.util.truncateMiddle
* decodes them to native bytes. The confirmed canonical JSON the FFI
* returns is shown on success — its `$id` is the on-chain document id a
* DOC-01 browse (chain query) then surfaces.
*
* Before broadcasting, a document whose type declares `propertyConstraints`
* (protocol version 14) is judged by Rust against those rules
* (`sdk.contracts.checkPropertyConstraints`, the check consensus runs); a
* broken rule is shown by name, violation and reason and nothing is sent. A
* check that cannot run (no SDK, no stored contract serialization) is logged
* and the create goes ahead, as on iOS.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -99,6 +108,7 @@ fun CreateDocumentScreen(
val container = LocalAppContainer.current
val appState = LocalAppState.current
val scope = rememberCoroutineScope()
val sdk by appState.sdk.collectAsStateWithLifecycle()
val network by appState.currentNetwork.collectAsStateWithLifecycle()
val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle()

Expand Down Expand Up @@ -129,6 +139,8 @@ fun CreateDocumentScreen(

var isSubmitting by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// The broken propertyConstraints rule that stopped the last submit.
var constraintError by remember { mutableStateOf<String?>(null) }
var createdDocId by remember { mutableStateOf<String?>(null) }
var createdJson by remember { mutableStateOf<String?>(null) }

Expand Down Expand Up @@ -283,6 +295,42 @@ fun CreateDocumentScreen(
isSubmitting = true
scope.launch {
try {
// Protocol version 14: consensus refuses a document
// breaking one of its type's propertyConstraints rules
// (error 10422) and still charges for the transition,
// so judge it first with the same Rust check.
val activeSdk = sdk
val check: (suspend (ByteArray) -> PropertyConstraintViolation?)? =
if (activeSdk == null) {
null
} else {
{ bytes ->
activeSdk.contracts.checkPropertyConstraints(
serializedContract = bytes,
documentType = typeName,
propertiesJson = propertiesJson,
ownerId = ownerId.identityId,
)
}
}
when (
val preCheck = propertyConstraintPreCheck(
schema,
contract?.binarySerialization,
check,
)
) {
is PropertyConstraintPreCheck.Broken -> {
constraintError = propertyConstraintViolationAlert(preCheck.violation)
return@launch
}
// Consensus judges the document either way.
is PropertyConstraintPreCheck.Skipped -> Log.w(
TAG,
"propertyConstraints pre-check could not run: ${preCheck.reason}",
)
PropertyConstraintPreCheck.Passed -> Unit
}
val json = mgr.documentTransactions.create(
walletHandle = wallet.handle,
ownerId = ownerId.identityId,
Expand All @@ -304,8 +352,15 @@ fun CreateDocumentScreen(
}

ErrorAlertDialog(message = error, onDismiss = { error = null })
ErrorAlertDialog(
message = constraintError,
title = PROPERTY_CONSTRAINT_BROKEN_TITLE,
onDismiss = { constraintError = null },
)
}

private const val TAG = "CreateDocumentScreen"

/**
* One schema-property editor, dispatched on the JSON-schema `type`. Shared
* by the create form and the DOC-03 replace form ([DocumentActionsScreen]),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.dashfoundation.example.ui.contracts

import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -22,19 +23,23 @@ 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.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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import org.dashfoundation.dashsdk.queries.DocumentPropertyConstraint
import org.dashfoundation.example.di.LocalAppContainer
import org.dashfoundation.example.di.LocalAppState
import org.dashfoundation.example.navigation.CountDocuments
import org.dashfoundation.example.navigation.Documents
import org.dashfoundation.example.navigation.NewDocument
Expand All @@ -52,6 +57,12 @@ import org.dashfoundation.example.util.hexToBytes
* The "New Document" affordance broadcasts a real create state transition
* via `CreateDocumentScreen` (port of iOS `CreateDocumentView`); the query
* actions (browse / count / sum-average) live alongside it.
*
* The "Property Constraints" section (protocol version 14) lists the rules
* Rust reads from the contract's stored platform serialization
* (`sdk.contracts.propertyConstraints`), re-read when the SDK learns the
* network's protocol version: the rules are read at that version, and none
* exist below 14.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -61,6 +72,9 @@ fun DocumentTypeDetailsScreen(
navController: NavHostController,
) {
val container = LocalAppContainer.current
val appState = LocalAppState.current
val sdk by appState.sdk.collectAsStateWithLifecycle()
val protocolVersion by appState.platformProtocolVersion.collectAsStateWithLifecycle()
val contractId = remember(contractIdHex) { contractIdHex.hexToBytes() }

val contractFlow = remember(contractIdHex) {
Expand All @@ -69,6 +83,9 @@ fun DocumentTypeDetailsScreen(
val contract by contractFlow.collectAsStateWithLifecycle(initialValue = null)

var expandedIndices by rememberSaveable { mutableStateOf(setOf<String>()) }
var constraintsSection by remember(contractIdHex, typeName) {
mutableStateOf<PropertyConstraintsSection>(PropertyConstraintsSection.Hidden)
}

Scaffold(
topBar = {
Expand All @@ -92,6 +109,18 @@ fun DocumentTypeDetailsScreen(
}
val capabilities = documentTypeCapabilities(schema, contractConfig)

LaunchedEffect(sdk, protocolVersion, current.lastUpdated, typeName) {
val activeSdk = sdk
val read: (suspend (ByteArray) -> List<DocumentPropertyConstraint>)? =
if (activeSdk == null) {
null
} else {
{ bytes -> activeSdk.contracts.propertyConstraints(bytes, typeName) }
}
constraintsSection =
loadPropertyConstraintsSection(schema, current.binarySerialization, read)
}

val properties = schema.objectField("properties") ?: JsonObject(emptyMap())
val indices = schema.arrayField("indices")?.mapNotNull { it as? JsonObject }.orEmpty()
val required = schema.arrayField("required")
Expand Down Expand Up @@ -195,6 +224,8 @@ fun DocumentTypeDetailsScreen(
}
}

PropertyConstraintsFormSection(constraintsSection)

if (indices.isNotEmpty()) {
FormSection(title = "Indices (${indices.size})") {
indices.sortedBy { it.stringField("name").orEmpty() }.forEach { index ->
Expand Down Expand Up @@ -321,6 +352,111 @@ fun DocumentTypeDetailsScreen(
}
}

/**
* The protocol-version-14 `propertyConstraints` rules: named conditions every
* created or replaced document must meet, checked in name order. Rust reads
* them from the stored contract; this section only shows what it reports
* (← `propertyConstraintsSection` in DocumentTypeDetailsView.swift).
*/
@Composable
private fun PropertyConstraintsFormSection(section: PropertyConstraintsSection) {
when (section) {
PropertyConstraintsSection.Hidden -> Unit

is PropertyConstraintsSection.Rules -> FormSection(
title = "Property Constraints (${section.rules.size})",
modifier = Modifier.testTag("documentType.propertyConstraints"),
) {
section.rules.forEach { rule -> PropertyConstraintRow(rule) }
Text(
"Every created or replaced document must meet each rule, checked in name " +
"order. A document breaking one is refused (error 10422) and the fee " +
"is still charged.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

PropertyConstraintsSection.NotEnforced -> FormSection(
title = "Property Constraints",
modifier = Modifier.testTag("documentType.propertyConstraints"),
) {
Text(
"The schema declares propertyConstraints, but the network's protocol " +
"version does not enforce them (they take effect at protocol version 14).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

is PropertyConstraintsSection.Unavailable -> FormSection(
title = "Property Constraints",
modifier = Modifier.testTag("documentType.propertyConstraints"),
) {
Text(
section.reason,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}

/**
* One `propertyConstraints` rule: its name, the rule as declared, what it
* reads, and whether an owner change is judged against it too
* (← `PropertyConstraintRowView` in DocumentTypeDetailsView.swift).
*/
@Composable
private fun PropertyConstraintRow(rule: DocumentPropertyConstraint) {
val prettyRule = remember(rule) { rule.prettyRuleJson }
val reads = remember(rule) { propertyConstraintReadsText(rule) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.testTag("documentType.propertyConstraint.${rule.name}"),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(rule.name, style = MaterialTheme.typography.titleSmall)
if (rule.readsOwner) {
Text(
"\$ownerId",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
Text(
prettyRule,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
softWrap = false,
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 4.dp),
)
if (reads.isNotEmpty()) {
Text(
"Reads: $reads",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (rule.readsOwner) {
Text(
"Reads \$ownerId, the document's owner: transfers and purchases are judged " +
"against this rule too.",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
}

/** One schema property row (← `PropertyRowView` in DocumentTypeDetailsView.swift). */
@Composable
private fun PropertyRow(name: String, property: JsonObject, isRequired: Boolean) {
Expand Down
Loading
Loading