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,6 +1,8 @@
package org.dashfoundation.example.di

import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -47,6 +49,10 @@ class AppContainer(private val context: Context) {
dashPayActiveIdentityStore,
)

/** Once-per-identity distributions this device knows were already claimed. */
val oncePerIdentityClaimStore =
org.dashfoundation.example.services.tokens.OncePerIdentityClaimStore(dataStore)

val appUiState = AppUiState()

/** Fast-cadence SPV progress feed for the global overlay (A-M4 wires the source). */
Expand Down Expand Up @@ -321,13 +327,34 @@ class AppContainer(private val context: Context) {
appState.restorePreferences()
appState.initializeSdk()
loadKnownContractsIntoSdk()
backfillOncePerIdentityDistributions()
activateManager()
_bootstrapState.value = BootstrapState.Ready
} catch (e: Exception) {
_bootstrapState.value = BootstrapState.Failed(e)
}
}

/**
* One-time pass after the schema version 14 migration: token rows written
* by an earlier build have no once-per-identity block even when their
* stored contract carries one (see
* `TokenMaterializer.backfillOncePerIdentityDistributions`). Best-effort
* and non-fatal, and it only marks itself done when it ran to the end.
*/
private suspend fun backfillOncePerIdentityDistributions() {
try {
if (dataStore.data.first()[ONCE_PER_IDENTITY_BACKFILL_DONE] == true) return
val contracts = database.dataContractDao().observeWithTokens().first()
val filled = org.dashfoundation.example.services.tokens.TokenMaterializer
.backfillOncePerIdentityDistributions(contracts, database.tokenDao())
dataStore.edit { it[ONCE_PER_IDENTITY_BACKFILL_DONE] = true }
android.util.Log.i(TAG, "Backfilled $filled once-per-identity token blocks")
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to backfill once-per-identity token blocks", e)
}
}

/**
* Pre-load every locally-stored data contract into the SDK's trusted
* context provider — port of `AppState.loadKnownContractsIntoSDK`. This
Expand Down Expand Up @@ -373,5 +400,7 @@ class AppContainer(private val context: Context) {

private companion object {
const val TAG = "AppContainer"
val ONCE_PER_IDENTITY_BACKFILL_DONE =
booleanPreferencesKey("once_per_identity_backfill_done")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package org.dashfoundation.example.services.tokens

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import org.dashfoundation.example.util.toHex

/**
* Device-local memory of the once-per-identity distributions an identity
* already claimed (protocol version 14).
*
* Platform has no "has this identity claimed" query yet, and a second claim
* is a paid rejection (`TokenOncePerIdentityDistributionAlreadyClaimedError`,
* code 40722), so the app remembers what it learned: a claim it submitted
* that succeeded, or one that came back as already claimed. A fresh install
* starts empty and learns the state from the first rejection.
*/
class OncePerIdentityClaimStore(
private val dataStore: DataStore<Preferences>,
) {
fun observe(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray): Flow<Boolean> {
val key = preferenceKey(networkRaw, tokenId, identityId)
return dataStore.data
.map { preferences -> preferences[key] == true }
.catch { emit(false) }
}

suspend fun markClaimed(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) {
dataStore.edit { preferences ->
preferences[preferenceKey(networkRaw, tokenId, identityId)] = true
}
}

companion object {
/** Consensus code of `TokenOncePerIdentityDistributionAlreadyClaimedError`. */
const val ALREADY_CLAIMED_ERROR_CODE = 40722

/**
* The code as a number of its own. Error texts carry amounts and
* millisecond timestamps, and a claim time such as 1758140722000
* contains the digits, so a plain substring match would take an
* unrelated failure for a spent claim and hide the kind for good.
*/
private val ALREADY_CLAIMED_CODE_PATTERN =
Regex("(?<![0-9])$ALREADY_CLAIMED_ERROR_CODE(?![0-9])")

/**
* True when [error] is the already-claimed rejection. The native
* layer surfaces consensus errors as text, so match the code and the
* message rs-dpp renders for it.
*/
fun isAlreadyClaimed(error: Throwable): Boolean {
val message = generateSequence(error) { it.cause }
.mapNotNull { it.message }
.joinToString(" ")
return ALREADY_CLAIMED_CODE_PATTERN.containsMatchIn(message) ||
message.contains("already claimed the once-per-identity distribution")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun preferenceKey(networkRaw: Int, tokenId: ByteArray, identityId: ByteArray) =
booleanPreferencesKey(
"once_per_identity_claimed.$networkRaw.${tokenId.toHex()}.${identityId.toHex()}",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.serialization.json.jsonObject
import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity
import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity
import org.dashfoundation.dashsdk.persistence.entities.TokenEntity
import org.dashfoundation.dashsdk.tokens.TokenDistributionType
import org.dashfoundation.example.util.Base58
import org.dashfoundation.example.util.LenientJson

Expand Down Expand Up @@ -224,10 +225,16 @@ object TokenActionEvaluator {
*/
object TokenActionResolver {

/**
* [oncePerIdentityClaimed] is what the device knows about [identity]'s
* single once-per-identity claim on [token]
* ([OncePerIdentityClaimStore]); Platform has no query for it yet.
*/
fun resolve(
token: TokenEntity,
identity: IdentityEntity,
contract: DataContractEntity?,
oncePerIdentityClaimed: Boolean = false,
): List<ResolvedTokenAction> {
val rows = ArrayList<ResolvedTokenAction>(TokenActionKind.entries.size)

Expand Down Expand Up @@ -346,7 +353,10 @@ object TokenActionResolver {
}

// Claim.
rows += ResolvedTokenAction(TokenActionKind.CLAIM, resolveClaim(token, identity))
rows += ResolvedTokenAction(
TokenActionKind.CLAIM,
resolveClaim(token, identity, oncePerIdentityClaimed),
)

// Direct purchase — allowed whenever pricing rules exist and the
// token isn't paused. `PurchaseForm` fetches the configured price on
Expand Down Expand Up @@ -423,14 +433,22 @@ object TokenActionResolver {
private fun resolveClaim(
token: TokenEntity,
identity: IdentityEntity,
oncePerIdentityClaimed: Boolean,
): TokenActionPermission {
val hasPerpetual = token.perpetualDistribution != null
val hasPreProgrammed = token.preProgrammedDistribution != null
if (!hasPerpetual && !hasPreProgrammed) {
val hasOncePerIdentity = token.oncePerIdentityDistribution != null
if (!hasPerpetual && !hasPreProgrammed && !hasOncePerIdentity) {
return TokenActionPermission.Denied("Token has no distribution schedule")
}
// Once per identity: every identity may claim the fixed amount once.
// An identity this device does not know about is still rejected
// on-chain at submit time if it already claimed.
if (hasOncePerIdentity && !oncePerIdentityClaimed) {
return TokenActionPermission.Allowed
}

if (token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true) {
if (isDesignatedRecipient(token, identity)) {
return TokenActionPermission.Allowed
}
// Pre-programmed releases name their recipients in the contract;
Expand All @@ -439,6 +457,13 @@ object TokenActionResolver {
return TokenActionPermission.Allowed
}

// The single claim is spent and no other kind makes this identity
// eligible: say so instead of a recipient mismatch.
if (hasOncePerIdentity) {
return TokenActionPermission.Denied(
"Already claimed the once-per-identity distribution",
)
}
if (!hasPerpetual) {
return TokenActionPermission.Denied("Not a recipient of any pre-programmed release")
}
Expand All @@ -448,6 +473,47 @@ object TokenActionResolver {
return TokenActionPermission.Denied("Distribution eligibility not yet evaluated")
}

/**
* The distribution kinds [identity] can claim under [resolveClaim]'s
* rules, in the order the claim form shows them. A kind the token merely
* declares is not enough: perpetual is listed only for the identity it
* pays, pre-programmed only for a listed recipient, and once-per-identity
* only while the identity's single claim is not known to be spent.
* Offering anything else enables a claim Drive rejects for a fee.
*/
fun claimableDistributions(
token: TokenEntity,
identity: IdentityEntity,
oncePerIdentityClaimed: Boolean,
): List<TokenDistributionType> = buildList {
if (token.perpetualDistribution != null && isDesignatedRecipient(token, identity)) {
add(TokenDistributionType.PERPETUAL)
}
if (token.preProgrammedDistribution != null && isPreProgrammedRecipient(token, identity)) {
add(TokenDistributionType.PRE_PROGRAMMED)
}
if (token.oncePerIdentityDistribution != null && !oncePerIdentityClaimed) {
add(TokenDistributionType.ONCE_PER_IDENTITY)
}
}

/**
* The kind the claim form preselects: the first the identity is eligible
* for, or null when there is none. The Claim row opens for every identity
* once a token has a once-per-identity distribution, so defaulting to a
* kind the token only declares would steer an identity that is not its
* recipient into a paid wrong-claimant rejection.
*/
fun preferredClaimDistribution(
token: TokenEntity,
identity: IdentityEntity,
oncePerIdentityClaimed: Boolean,
): TokenDistributionType? =
claimableDistributions(token, identity, oncePerIdentityClaimed).firstOrNull()

private fun isDesignatedRecipient(token: TokenEntity, identity: IdentityEntity): Boolean =
token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true

/**
* True when [identity] is named as a recipient in ANY release of the
* token's pre-programmed distribution JSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ object TokenAmounts {
null
}

/**
* Normalize a raw on-chain amount given as a decimal string. Null when it
* is not an integer, is negative, or does not fit in a u64.
*/
fun parseRaw(text: String): String? = try {
val raw = BigInteger(text.trim())
if (raw.signum() >= 0 && raw <= MAX_U64) raw.toString() else null
} catch (_: NumberFormatException) {
null
}

/** Format a raw amount into display units, trimming trailing zeros. */
fun format(raw: ULong, decimals: Int): String = format(raw.toString(), decimals)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,29 @@ object TokenMaterializer {
}
}

/**
* Schema version 14 added `tokens.oncePerIdentityDistribution`, and the
* migration can only add the column: rows materialized by an earlier
* build keep a NULL block (and `hasDistribution = false`) although the
* contract JSON stored beside them may carry one. Re-read the block from
* each stored contract and fill it in where it is missing. Unlike
* [materialize] this never rewrites the rest of the row, so live columns
* such as `isPaused` keep their value. Returns the rows filled in.
*/
suspend fun backfillOncePerIdentityDistributions(
contracts: List<DataContractEntity>,
dao: TokenDao,
): Int {
var filled = 0
for (contract in contracts) {
for (token in parse(contract)) {
val block = token.oncePerIdentityDistribution ?: continue
filled += dao.backfillOncePerIdentityDistribution(token.id, block)
}
}
return filled
}

/** Pure parse of [contract]'s tokens map into entity rows. */
fun parse(contract: DataContractEntity): List<TokenEntity> {
val root = try {
Expand Down Expand Up @@ -80,6 +103,10 @@ object TokenMaterializer {
val distributionRules = dict.obj("distributionRules")
val perpetual = distributionRules?.obj("perpetualDistribution")
val preProgrammed = distributionRules?.obj("preProgrammedDistribution")
// Protocol version 14: a fixed amount every identity may claim once.
// Persisted raw like the two blocks above; the amount is read back
// through TokenOncePerIdentityDistribution.parse.
val oncePerIdentity = distributionRules?.obj("oncePerIdentityDistribution")
val newTokensDestination = distributionRules?.str("newTokensDestinationIdentity")
?.let { Base58.decodeIdentifier(it) }
val mintingAllowChoosing = distributionRules
Expand All @@ -106,7 +133,8 @@ object TokenMaterializer {
keepsHistoryObj?.boolean(key) ?: keepsHistoryAll ?: true

val now = Date()
val hasDistribution = perpetual != null || preProgrammed != null
val hasDistribution =
perpetual != null || preProgrammed != null || oncePerIdentity != null
return TokenEntity(
id = tokenId(contractId, position),
contractId = contractId,
Expand Down Expand Up @@ -138,6 +166,7 @@ object TokenMaterializer {
emergencyActionRules = emergencyAction?.toJson(),
perpetualDistribution = perpetual?.toString(),
preProgrammedDistribution = preProgrammed?.toString(),
oncePerIdentityDistribution = oncePerIdentity?.toString(),
newTokensDestinationIdentity = newTokensDestination,
mintingAllowChoosingDestination = mintingAllowChoosing,
distributionChangeRules = distributionChange?.toJson(),
Expand All @@ -149,7 +178,7 @@ object TokenMaterializer {
createdAt = now,
lastUpdatedAt = now,
// Capability columns — MUST stay == (rules column != null),
// and hasDistribution == (perpetual || preProgrammed).
// and hasDistribution == (perpetual || preProgrammed || oncePerIdentity).
canManuallyMint = manualMinting != null,
canManuallyBurn = manualBurning != null,
canFreeze = freeze != null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,41 @@ object AuthorizedActionTakers {
fun group(position: Int): String = "$GROUP_PREFIX$position"
fun identity(base58: String): String = "$IDENTITY_PREFIX$base58"
}

/**
* `oncePerIdentityDistribution` (protocol version 14): a fixed [amount]
* every identity may claim exactly once. Decoded view of the raw block
* [TokenMaterializer] persists in
* [org.dashfoundation.dashsdk.persistence.entities.TokenEntity.oncePerIdentityDistribution]
* (`{"$formatVersion":"0","amount":<u64>}`, the amount a JSON number or a
* decimal string). [amount] is a decimal string like every other raw token
* amount in the app.
*/
data class TokenOncePerIdentityDistribution(val amount: String) {

companion object {

fun parse(json: String?): TokenOncePerIdentityDistribution? {
if (json.isNullOrBlank()) return null
return try {
parse(LenientJson.parseToJsonElement(json).jsonObject)
} catch (_: Exception) {
null
}
}

/**
* Null when the block carries no `amount` that is a raw u64, the
* type the protocol gives token amounts. The range rs-dpp admits for
* this field (1 to `i64::MAX`) is validated in Rust at registration
* and is deliberately not mirrored here.
*/
fun parse(obj: JsonObject): TokenOncePerIdentityDistribution? {
// Tolerate the enum-wrapped rendering the pre-programmed
// resolver also accepts; rs-dpp itself emits the flat shape.
val body = (obj["V0"] as? JsonObject) ?: obj
val content = (body["amount"] as? JsonPrimitive)?.content ?: return null
return TokenAmounts.parseRaw(content)?.let(::TokenOncePerIdentityDistribution)
}
}
}
Loading
Loading