Skip to content

Commit

Permalink
STUD-325: Create Earnings fee transaction
Browse files Browse the repository at this point in the history
  • Loading branch information
AndrewWestberg committed Sep 14, 2024
1 parent df810d1 commit 886440a
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package io.newm.server.features.earnings

import com.google.iot.cbor.CborInteger
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Routing
import io.ktor.server.routing.route
import io.newm.chain.util.extractStakeAddress
import io.newm.chain.util.toHexString
import io.newm.server.auth.jwt.AUTH_JWT
import io.newm.server.auth.jwt.AUTH_JWT_ADMIN
import io.newm.server.config.repo.ConfigRepository
import io.newm.server.config.repo.ConfigRepository.Companion.CONFIG_KEY_EARNINGS_CLAIM_ORDER_FEE
import io.newm.server.features.cardano.repo.CardanoRepository
import io.newm.server.features.earnings.model.AddSongRoyaltyRequest
import io.newm.server.features.earnings.model.ClaimOrderRequest
import io.newm.server.features.earnings.model.Earning
import io.newm.server.features.earnings.model.GetEarningsResponse
import io.newm.server.features.earnings.repo.EarningsRepository
Expand All @@ -25,6 +30,7 @@ private const val EARNINGS_PATH = "v1/earnings"
private const val EARNINGS_PATH_ADMIN = "v1/earnings/admin"

fun Routing.createEarningsRoutes() {
val configRepository: ConfigRepository by inject()
val cardanoRepository: CardanoRepository by inject()
val earningsRepository: EarningsRepository by inject()
val recaptchaRepository: RecaptchaRepository by inject()
Expand Down Expand Up @@ -57,25 +63,32 @@ fun Routing.createEarningsRoutes() {

// Claiming is un-authenticated, but we still check recaptcha to prevent bots
authenticate(AUTH_JWT, optional = true) {
route("$EARNINGS_PATH/{walletAddress}") {
route(EARNINGS_PATH) {
// get earnings
get {
recaptchaRepository.verify("getearnings_$walletAddress", request)
get("{walletAddress}") {
val stakeAddress = parameters["walletAddress"]!!.extractStakeAddress(cardanoRepository.isMainnet())
recaptchaRepository.verify("getearnings_$stakeAddress", request)
val earnings = earningsRepository.getAllByStakeAddress(stakeAddress)
// calculate sum of claimed earnings
val totalClaimed = earnings.filter { it.claimed }.sumOf { it.amount }
val paymentAmountLovelace = configRepository.getLong(CONFIG_KEY_EARNINGS_CLAIM_ORDER_FEE)
val changeAmountLovelace = 1000000L // 1 ada
val amountCborHex = CborInteger
.create(paymentAmountLovelace + changeAmountLovelace)
.toCborByteArray()
.toHexString()
respond(
GetEarningsResponse(
totalClaimed = earnings.filter { it.claimed }.sumOf { it.amount },
earnings = earnings
totalClaimed = totalClaimed,
earnings = earnings,
amountCborHex = amountCborHex,
)
)
}
// create a claim for all earnings on this wallet stake address
post {
recaptchaRepository.verify("postearnings_$walletAddress", request)
val stakeAddress = parameters["walletAddress"]!!.extractStakeAddress(cardanoRepository.isMainnet())
val claimOrder = earningsRepository.createClaimOrder(stakeAddress)
val claimOrderRequest = receive<ClaimOrderRequest>()
recaptchaRepository.verify("postearnings_${claimOrderRequest.walletAddress}", request)
val claimOrder = earningsRepository.createClaimOrder(claimOrderRequest)

if (claimOrder != null) {
respond(claimOrder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ class ClaimOrderEntity(
failedEarningsIds = failedEarningsIds?.toList(),
transactionId = transactionId,
createdAt = createdAt,
errorMessage = errorMessage
errorMessage = errorMessage,
cborHex = "",
)

companion object : UUIDEntityClass<ClaimOrderEntity>(ClaimOrdersTable)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ data class ClaimOrder(
val transactionId: String?,
@Contextual
val createdAt: LocalDateTime,
val errorMessage: String?
val errorMessage: String?,
val cborHex: String,
) {
companion object {
val ACTIVE_STATUSES = listOf(ClaimOrderStatus.Pending, ClaimOrderStatus.Processing).map { it.name }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package io.newm.server.features.earnings.model

import io.newm.chain.grpc.Utxo
import io.newm.server.ktx.cborHexToUtxos
import kotlinx.serialization.Serializable

@Serializable
data class ClaimOrderRequest(
val walletAddress: String,
val changeAddress: String,
val utxoCborHexList: List<String>,
) {
val utxos: List<Utxo> by lazy { utxoCborHexList.cborHexToUtxos() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable
data class GetEarningsResponse(
val earnings: List<Earning>,
val totalClaimed: Long
val totalClaimed: Long,
val amountCborHex: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.newm.server.features.earnings.repo

import io.newm.server.features.earnings.model.AddSongRoyaltyRequest
import io.newm.server.features.earnings.model.ClaimOrder
import io.newm.server.features.earnings.model.ClaimOrderRequest
import io.newm.server.features.earnings.model.Earning
import io.newm.server.typealiases.SongId
import java.util.UUID
Expand Down Expand Up @@ -33,7 +34,7 @@ interface EarningsRepository {
/**
* Create a new claim order for a stake address
*/
suspend fun createClaimOrder(stakeAddress: String): ClaimOrder?
suspend fun createClaimOrder(claimOrderRequest: ClaimOrderRequest): ClaimOrder?

/**
* Update a claim order
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.newm.server.features.earnings.repo

import io.github.oshai.kotlinlogging.KotlinLogging
import io.newm.chain.grpc.outputUtxo
import io.newm.chain.util.extractStakeAddress
import io.newm.server.config.repo.ConfigRepository
import io.newm.server.config.repo.ConfigRepository.Companion.CONFIG_KEY_EARNINGS_CLAIM_ORDER_FEE
import io.newm.server.features.cardano.database.KeyTable
Expand All @@ -15,17 +17,21 @@ import io.newm.server.features.earnings.database.EarningsTable
import io.newm.server.features.earnings.model.AddSongRoyaltyRequest
import io.newm.server.features.earnings.model.ClaimOrder
import io.newm.server.features.earnings.model.ClaimOrder.Companion.ACTIVE_STATUSES
import io.newm.server.features.earnings.model.ClaimOrderRequest
import io.newm.server.features.earnings.model.ClaimOrderStatus
import io.newm.server.features.earnings.model.Earning
import io.newm.server.features.song.database.SongTable
import io.newm.server.features.song.repo.SongRepository
import io.newm.server.features.user.repo.UserRepository
import io.newm.server.typealiases.SongId
import io.newm.shared.koin.inject
import io.newm.shared.ktx.toDate
import io.newm.shared.ktx.toHexString
import java.sql.Connection.TRANSACTION_SERIALIZABLE
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
import org.apache.curator.framework.recipes.locks.InterProcessMutex
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
Expand All @@ -34,11 +40,14 @@ import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.batchInsert
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
import org.jetbrains.exposed.sql.transactions.transaction
import org.koin.core.parameter.parametersOf
import org.quartz.JobBuilder.newJob
import org.quartz.JobKey
import org.quartz.SimpleScheduleBuilder.simpleSchedule
import org.quartz.TriggerBuilder.newTrigger

private const val CLAIM_ORDER_INTER_PROCESS_MUTEX_PATH = "/mutexes/claim-order"

class EarningsRepositoryImpl(
private val userRepository: UserRepository,
private val songRepository: SongRepository,
Expand All @@ -47,6 +56,7 @@ class EarningsRepositoryImpl(
private val schedulerDaemon: MonitorClaimOrderSchedulerDaemon,
) : EarningsRepository {
private val log = KotlinLogging.logger {}
private val claimOrderMutex: InterProcessMutex by inject { parametersOf(CLAIM_ORDER_INTER_PROCESS_MUTEX_PATH) }

override suspend fun add(earning: Earning): UUID =
transaction {
Expand Down Expand Up @@ -188,10 +198,20 @@ class EarningsRepositoryImpl(
}
}

override suspend fun createClaimOrder(stakeAddress: String): ClaimOrder? {
override suspend fun createClaimOrder(claimOrderRequest: ClaimOrderRequest): ClaimOrder? {
// create any claim orders one at a time to ensure a flood attack can't create a bunch of dup claim orders
claimOrderMutex.acquire()
try {
return createClaimOrderInternal(claimOrderRequest)
} finally {
claimOrderMutex.release()
}
}

private suspend fun createClaimOrderInternal(claimOrderRequest: ClaimOrderRequest): ClaimOrder? {
val claimOrder =
newSuspendedTransaction(transactionIsolation = TRANSACTION_SERIALIZABLE) {
val stakeAddress = claimOrderRequest.walletAddress.extractStakeAddress(cardanoRepository.isMainnet())
// check for existing open claim record first
getActiveClaimOrderByStakeAddress(stakeAddress) ?: run {
// create a new claim record
Expand All @@ -214,14 +234,31 @@ class EarningsRepositoryImpl(
transactionId = null,
createdAt = LocalDateTime.now(),
errorMessage = null,
cborHex = "",
)

val id = add(claimOrder)
claimOrder.copy(id = id)
}
}

return claimOrder?.also { monitor(it) }
val cborHex = claimOrder
?.let {
cardanoRepository
.buildTransaction {
this.sourceUtxos.addAll(claimOrderRequest.utxos)
this.outputUtxos.add(
outputUtxo {
address = claimOrder.paymentAddress
lovelace = claimOrder.paymentAmount.toString()
}
)
this.changeAddress = claimOrderRequest.changeAddress
}.transactionCbor
.toByteArray()
.toHexString()
}.orEmpty()
return claimOrder?.copy(cborHex = cborHex)?.also { monitor(it) }
}

override suspend fun getAll(): List<Earning> =
Expand Down
31 changes: 25 additions & 6 deletions newm-server/src/main/resources/openapi/documentation.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -597,16 +597,17 @@ paths:
type: "array"
items:
$ref: "#/components/schemas/Earning"
/v1/earnings:
post:
tags:
- "Earnings"
description: "create a claim for all earnings on this wallet stake address"
parameters:
- name: "walletAddress"
in: "path"
required: true
schema:
type: "string"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ClaimOrderRequest"
required: true
responses:
"200":
description: "OK"
Expand Down Expand Up @@ -2311,6 +2312,21 @@ components:
- "stakeAddress"
- "memo"
- "createdAt"
ClaimOrderRequest:
type: "object"
properties:
walletAddress:
type: "string"
changeAddress:
type: "string"
utxoCborHexList:
type: "array"
items:
type: "string"
required:
- "walletAddress"
- "changeAddress"
- "utxoCborHexList"
ClaimOrder:
type: "object"
properties:
Expand Down Expand Up @@ -2353,13 +2369,16 @@ components:
format: "date-time"
errorMessage:
type: "string"
cborHex:
type: "string"
required:
- "stakeAddress"
- "keyId"
- "paymentAddress"
- "status"
- "earningsIds"
- "createdAt"
- "cborHex"
BigInteger:
type: "integer"
AddSongRoyaltyRequest:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ class EarningsRoutesTests : BaseApplicationTests() {
Earning(id = earning1, amount = 200, stakeAddress = testStakeAddress1, memo = "default", createdAt = createdAt),
Earning(id = earning2, amount = 300, stakeAddress = testStakeAddress1, memo = "default", createdAt = createdAt),
Earning(id = earning3, amount = 500, stakeAddress = testStakeAddress1, memo = "default", createdAt = createdAt),
)
),
amountCborHex = "1a002dc6c0" // 2 ada for minutxo + 1 ada change
)

// Get it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ class TransactionBuilder(
private fun createScriptDataHash() {
if (scriptDataHash == null && (!redeemers.isNullOrEmpty() || !datums.isNullOrEmpty())) {
// calculate the scriptDataHash - // redeemerBytes + datumBytes + languageViewMap
val redeemerBytes = createRedeemerWitnesses()?.toCborByteArray() ?: ByteArray(1) { 0x80.toByte() }
val redeemerBytes = createRedeemerWitnesses()?.toCborByteArray() ?: ByteArray(1) { 0xa0.toByte() }
val datumBytes = createDatumWitnesses()?.toCborByteArray() ?: ByteArray(0)
val languageViewMap =
if (!redeemers.isNullOrEmpty()) {
Expand Down

0 comments on commit 886440a

Please sign in to comment.