Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -426,9 +426,15 @@ object TokenActionResolver {
): 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 that already claimed is rejected on-chain at submit time.
if (hasOncePerIdentity) {
return TokenActionPermission.Allowed
}

if (token.newTokensDestinationIdentity?.contentEquals(identity.identityId) == true) {
return TokenActionPermission.Allowed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,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 +110,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 +143,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 +155,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,42 @@ 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] stays a decimal string because u64 values
* overflow Long.
*/
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 non-negative integer `amount`. */
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
val amount = try {
java.math.BigInteger(content)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (_: NumberFormatException) {
return null
}
if (amount.signum() < 0) return null
return TokenOncePerIdentityDistribution(amount.toString())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,9 @@ private fun ClaimForm(
val available = buildList {
if (token.perpetualDistribution != null) add(TokenDistributionType.PERPETUAL)
if (token.preProgrammedDistribution != null) add(TokenDistributionType.PRE_PROGRAMMED)
if (token.oncePerIdentityDistribution != null) {
add(TokenDistributionType.ONCE_PER_IDENTITY)
}
}
var selectedOrdinal by rememberSaveable {
mutableStateOf((available.firstOrNull() ?: TokenDistributionType.PERPETUAL).ordinal)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import org.dashfoundation.example.navigation.TokenActionPermissions
import org.dashfoundation.example.services.tokens.ChangeControlRules
import org.dashfoundation.example.services.tokens.TokenAmounts
import org.dashfoundation.example.services.tokens.TokenDistributionChangeRules
import org.dashfoundation.example.services.tokens.TokenOncePerIdentityDistribution
import org.dashfoundation.example.ui.components.FormSection
import org.dashfoundation.example.ui.components.LabeledContent
import org.dashfoundation.example.util.Base58
Expand Down Expand Up @@ -187,6 +188,7 @@ fun TokenDetailsScreen(tokenIdHex: String, navController: NavHostController) {

if (current.perpetualDistribution != null ||
current.preProgrammedDistribution != null ||
current.oncePerIdentityDistribution != null ||
current.newTokensDestinationIdentity != null
) {
FormSection(title = "Distribution") {
Expand All @@ -198,6 +200,12 @@ fun TokenDetailsScreen(tokenIdHex: String, navController: NavHostController) {
"Pre-programmed",
if (current.preProgrammedDistribution != null) "Configured" else "None",
)
LabeledContent(
"Once per identity",
TokenOncePerIdentityDistribution.parse(current.oncePerIdentityDistribution)
?.let { "${TokenAmounts.format(it.amount, current.decimals)} per identity" }
?: "None",
)
current.newTokensDestinationIdentity?.let {
LabeledContent(
"Destination Identity",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import org.junit.Test
* `newTokensDestinationIdentity` (perpetual) path plus pre-programmed
* recipients named in the contract's `distributions` map
* (`{"$formatVersion":"0","distributions":{"<timestampMs>":{"<base58>":amount}}}`,
* the shape [TokenMaterializer] persists).
* the shape [TokenMaterializer] persists), and the once-per-identity kind
* (protocol version 14) that makes every identity eligible once.
*/
class TokenActionResolverClaimTest {

Expand All @@ -28,6 +29,7 @@ class TokenActionResolverClaimTest {
perpetual: String? = null,
destination: ByteArray? = null,
mintingAllowChoosing: Boolean = true,
oncePerIdentity: String? = null,
) = TokenEntity(
id = ByteArray(36),
contractId = ByteArray(32),
Expand All @@ -36,9 +38,10 @@ class TokenActionResolverClaimTest {
baseSupply = "1000",
perpetualDistribution = perpetual,
preProgrammedDistribution = preProgrammed,
oncePerIdentityDistribution = oncePerIdentity,
newTokensDestinationIdentity = destination,
mintingAllowChoosingDestination = mintingAllowChoosing,
hasDistribution = preProgrammed != null || perpetual != null,
hasDistribution = preProgrammed != null || perpetual != null || oncePerIdentity != null,
)

private fun claim(token: TokenEntity, identity: IdentityEntity): TokenActionPermission =
Expand All @@ -49,6 +52,9 @@ class TokenActionResolverClaimTest {
private fun preProgrammedJson(recipient: String = recipientBase58): String =
"""{"${'$'}formatVersion":"0","distributions":{"1750000000000":{"$recipient":5000}}}"""

private fun oncePerIdentityJson(amount: String = "5000"): String =
"""{"${'$'}formatVersion":"0","amount":$amount}"""

@Test
fun `no distribution schedule is denied`() {
assertEquals(
Expand Down Expand Up @@ -125,6 +131,24 @@ class TokenActionResolverClaimTest {
)
}

@Test
fun `once-per-identity distribution allows any identity`() {
assertTrue(claim(token(oncePerIdentity = oncePerIdentityJson()), identity()).isAllowed)
assertTrue(
claim(token(oncePerIdentity = oncePerIdentityJson()), identity(strangerId)).isAllowed,
)
}

@Test
fun `once-per-identity distribution allows a non-recipient of the pre-programmed releases`() {
assertTrue(
claim(
token(preProgrammed = preProgrammedJson(), oncePerIdentity = oncePerIdentityJson()),
identity(strangerId),
).isAllowed,
)
}

@Test
fun `perpetual-only non-designated identity keeps the existing denials`() {
assertEquals(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package org.dashfoundation.example.services.tokens

import org.dashfoundation.dashsdk.persistence.entities.DataContractEntity
import org.dashfoundation.dashsdk.persistence.entities.TokenEntity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* [TokenMaterializer] coverage for the `oncePerIdentityDistribution` block
* (protocol version 14): the raw block lands in
* [TokenEntity.oncePerIdentityDistribution], flips `hasDistribution`, and
* [TokenOncePerIdentityDistribution.parse] reads the u64 amount back as a
* decimal string whether the contract encoded it as a number or a string.
*/
class TokenMaterializerOncePerIdentityTest {

private val contractId = ByteArray(32) { 0xCD.toByte() }

private fun contract(distributionRules: String): DataContractEntity = DataContractEntity(
id = contractId,
name = "Fixture",
serializedContract =
"""{"tokens":{"0":{"baseSupply":0,"distributionRules":$distributionRules}}}"""
.encodeToByteArray(),
networkRaw = 0,
)

private fun parseSingleToken(distributionRules: String): TokenEntity {
val tokens = TokenMaterializer.parse(contract(distributionRules))
assertEquals(1, tokens.size)
return tokens.single()
}

private fun block(amount: String): String =
"""{"oncePerIdentityDistribution":{"${'$'}formatVersion":"0","amount":$amount}}"""

@Test
fun `numeric amount is persisted raw and parses to a decimal string`() {
val token = parseSingleToken(block("5000"))

val raw = token.oncePerIdentityDistribution
assertNotNull(raw)
assertTrue(token.hasDistribution)
assertEquals("5000", TokenOncePerIdentityDistribution.parse(raw)?.amount)
}

@Test
fun `string amount parses to the same decimal string`() {
val token = parseSingleToken(block("\"12345\""))

assertEquals(
"12345",
TokenOncePerIdentityDistribution.parse(token.oncePerIdentityDistribution)?.amount,
)
}

@Test
fun `amounts above Long MAX_VALUE survive verbatim as number and as string`() {
val huge = "18446744073709551615" // UInt64.max

val asNumber = parseSingleToken(block(huge))
assertEquals(
huge,
TokenOncePerIdentityDistribution.parse(asNumber.oncePerIdentityDistribution)?.amount,
)

val asString = parseSingleToken(block("\"$huge\""))
assertEquals(
huge,
TokenOncePerIdentityDistribution.parse(asString.oncePerIdentityDistribution)?.amount,
)
}

@Test
fun `absent block leaves the column null and hasDistribution false`() {
val token = parseSingleToken("""{"mintingAllowChoosingDestination":true}""")

assertNull(token.oncePerIdentityDistribution)
assertFalse(token.hasDistribution)
}

@Test
fun `block alongside a pre-programmed distribution keeps both`() {
val preProgrammed =
"""{"${'$'}formatVersion":"0","distributions":{"1750000000000":{"abc":1}}}"""
val token = parseSingleToken(
"""{"preProgrammedDistribution":$preProgrammed,""" +
""""oncePerIdentityDistribution":{"${'$'}formatVersion":"0","amount":9}}""",
)

assertNotNull(token.preProgrammedDistribution)
assertEquals(
"9",
TokenOncePerIdentityDistribution.parse(token.oncePerIdentityDistribution)?.amount,
)
assertTrue(token.hasDistribution)
}

@Test
fun `parse rejects a block without a valid amount`() {
assertNull(TokenOncePerIdentityDistribution.parse("""{"${'$'}formatVersion":"0"}"""))
assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":"abc"}"""))
assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":-1}"""))
assertNull(TokenOncePerIdentityDistribution.parse("""{"amount":1.5}"""))
assertNull(TokenOncePerIdentityDistribution.parse("not json"))
assertNull(TokenOncePerIdentityDistribution.parse(""))
assertNull(TokenOncePerIdentityDistribution.parse(null))
}

@Test
fun `V0-wrapped block is unwrapped`() {
assertEquals("7", TokenOncePerIdentityDistribution.parse("""{"V0":{"amount":7}}""")?.amount)
}
}
Loading
Loading