diff --git a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/WalletAppReference.scala b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/WalletAppReference.scala index 535f2e2229..801f6cf9cf 100644 --- a/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/WalletAppReference.scala +++ b/apps/app/src/main/scala/org/lfdecentralizedtrust/splice/console/WalletAppReference.scala @@ -18,6 +18,8 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{ AllocateAmuletResponse, GetBuyTrafficRequestStatusResponse, GetTransferOfferStatusResponse, + ListMintingDelegationProposalsResponse, + ListMintingDelegationsResponse, TransferInstructionResultResponse, } import org.lfdecentralizedtrust.splice.util.{Contract, ContractWithState} @@ -600,6 +602,57 @@ abstract class WalletAppReference( httpCommand(HttpWalletAppClient.TokenStandard.RejectAllocationRequest(id)) } } + + @Help.Summary("List MintingDelegationProposals") + @Help.Description( + "List all MintingDelegationProposal contracts where the user is the delegate." + ) + def listMintingDelegationProposals( + after: Option[Long] = None, + limit: Option[Int] = None, + ): ListMintingDelegationProposalsResponse = + consoleEnvironment.run { + httpCommand(HttpWalletAppClient.ListMintingDelegationProposals(after, limit)) + } + + @Help.Summary("Accept MintingDelegationProposal") + @Help.Description( + "Accept a MintingDelegationProposal, creating a MintingDelegation contract and archiving an existing contract." + ) + def acceptMintingDelegationProposal(contractId: String): String = + consoleEnvironment.run { + httpCommand(HttpWalletAppClient.AcceptMintingDelegationProposal(contractId)) + } + + @Help.Summary("Reject MintingDelegationProposal") + @Help.Description( + "Reject a MintingDelegationProposal." + ) + def rejectMintingDelegationProposal(contractId: String): Unit = + consoleEnvironment.run { + httpCommand(HttpWalletAppClient.RejectMintingDelegationProposal(contractId)) + } + + @Help.Summary("List MintingDelegations") + @Help.Description( + "List all MintingDelegation contracts where the user is the delegate." + ) + def listMintingDelegations( + after: Option[Long] = None, + limit: Option[Int] = None, + ): ListMintingDelegationsResponse = + consoleEnvironment.run { + httpCommand(HttpWalletAppClient.ListMintingDelegations(after, limit)) + } + + @Help.Summary("Reject MintingDelegation") + @Help.Description( + "Reject/terminate a MintingDelegation contract." + ) + def rejectMintingDelegation(contractId: String): Unit = + consoleEnvironment.run { + httpCommand(HttpWalletAppClient.RejectMintingDelegation(contractId)) + } } /** Client (aka remote) reference to a wallet app in the style of ParticipantClientReference, i.e., diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala new file mode 100644 index 0000000000..f770f01c9f --- /dev/null +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala @@ -0,0 +1,675 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.integration.tests + +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{ + Amulet, + AppRewardCoupon, + DevelopmentFundCoupon, + UnclaimedActivityRecord, + ValidatorRewardCoupon, + ValidatorRight, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.validatorlicense.ValidatorLivenessActivityRecord +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation as mintingDelegationCodegen +import org.lfdecentralizedtrust.splice.config.ConfigTransforms +import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition +import org.lfdecentralizedtrust.splice.automation.Trigger +import org.lfdecentralizedtrust.splice.console.ValidatorAppBackendReference +import org.lfdecentralizedtrust.splice.wallet.automation.{ + CollectRewardsAndMergeAmuletsTrigger, + MintingDelegationCollectRewardsTrigger, +} +import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.{ + IntegrationTest, + SpliceTestConsoleEnvironment, +} +import org.lfdecentralizedtrust.splice.store.Limit +import org.lfdecentralizedtrust.splice.util.{TimeTestUtil, TriggerTestUtil, WalletTestUtil} +import com.digitalasset.canton.topology.PartyId + +import java.time.Duration +import scala.jdk.CollectionConverters.* + +class WalletMintingDelegationTimeBasedIntegrationTest + extends IntegrationTest + with WalletTestUtil + with TimeTestUtil + with TriggerTestUtil + with ExternallySignedPartyTestUtil { + + private val DefaultAmuletMergeLimit = 10 + + // We create many coupons directly, so avoid running sanity checks + override protected def runUpdateHistorySanityCheck: Boolean = false + override protected def runTokenStandardCliSanityCheck: Boolean = false + + override def environmentDefinition: SpliceEnvironmentDefinition = + EnvironmentDefinition + .simpleTopology1SvWithSimTime(this.getClass.getSimpleName) + .withTrafficTopupsDisabled + .addConfigTransforms((_, config) => + ConfigTransforms.updateAllSvAppFoundDsoConfigs_( + _.copy(zeroTransferFees = true) + )(config) + ) + + "Wallet MintingDelegation APIs" should { + "allow validator to list, accept, and reject minting delegation proposals and delegations" in { + implicit env => + val validatorParty = aliceValidatorBackend.getValidatorPartyId() + + aliceValidatorWalletClient.tap(100.0) + + val beneficiaryParty = onboardExternalParty(aliceValidatorBackend, Some("beneficiary")) + + val expiresAt = env.environment.clock.now.plus(Duration.ofDays(30)).toInstant + + // Use a separate party to test that its proposals/delegation remain + // unaffected when modifying beneficiaryParty's proposals/delegations + clue("Init setup: create a delegation + proposal for beneficiary2Party") { + val beneficiary2Party = + onboardExternalParty(aliceValidatorBackend, Some("beneficiary2")) + createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiary2Party) + + // Verify initial state + aliceValidatorWalletClient.listMintingDelegationProposals().proposals shouldBe empty + aliceValidatorWalletClient.listMintingDelegations().delegations shouldBe empty + + val (_, proposal0Cid) = actAndCheck( + "Create minting delegation proposal for beneficiary2", + createMintingDelegationProposal(beneficiary2Party, validatorParty, expiresAt), + )( + "Proposal is visible to validator", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 1 + proposals.proposals.head.contract.contractId + }, + ) + + actAndCheck( + "Accept proposal and create delegation for beneficiary2", + aliceValidatorWalletClient.acceptMintingDelegationProposal(proposal0Cid), + )( + "Delegation is created", + _ => { + val delegations = aliceValidatorWalletClient.listMintingDelegations() + delegations.delegations should have size 1 + }, + ) + + actAndCheck( + "Create another proposal for beneficiary2", + createMintingDelegationProposal(beneficiary2Party, validatorParty, expiresAt), + )( + "Proposal is visible to validator", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 1 + }, + ) + } + + // Test 1 + clue("Test beneficiaryOnboarded status") { + val (_, proposalBeforeOnboardingCid) = actAndCheck( + "Create minting delegation proposal before beneficiary is onboarded", + createMintingDelegationProposal(beneficiaryParty, validatorParty, expiresAt), + )( + "Proposal is visible with beneficiaryOnboarded = false", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + val beneficiaryProposal = proposals.proposals + .find( + _.contract.payload.hcursor + .downField("delegation") + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + beneficiaryProposal.beneficiaryOnboarded shouldBe false + beneficiaryProposal.contract.contractId + }, + ) + + // Accept the proposal before onboarding and verify beneficiaryOnboarded = false in delegations + actAndCheck( + "Accept proposal before beneficiary is onboarded", + aliceValidatorWalletClient.acceptMintingDelegationProposal(proposalBeforeOnboardingCid), + )( + "Delegation is visible with beneficiaryOnboarded = false", + _ => { + val delegations = aliceValidatorWalletClient.listMintingDelegations() + delegations.delegations should have size 2 + val beneficiaryDelegation = delegations.delegations + .find( + _.contract.payload.hcursor + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + beneficiaryDelegation.beneficiaryOnboarded shouldBe false + }, + ) + } + + // Onboard beneficiary + createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiaryParty) + + clue("After onboarding, beneficiaryOnboarded should be true in delegations") { + val delegations = aliceValidatorWalletClient.listMintingDelegations() + val beneficiaryDelegation = delegations.delegations + .find( + _.contract.payload.hcursor + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + beneficiaryDelegation.beneficiaryOnboarded shouldBe true + } + + // Test 2: Creates a proposal and test reject + clue("Test reject minting delegation proposal") { + val (_, proposal1Cid) = actAndCheck( + "Create minting delegation proposal", + createMintingDelegationProposal(beneficiaryParty, validatorParty, expiresAt), + )( + "Proposal is visible to validator", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + proposals.proposals + .find( + _.contract.payload.hcursor + .downField("delegation") + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + .contract + .contractId + }, + ) + + actAndCheck( + "Validator rejects the proposal", + aliceValidatorWalletClient.rejectMintingDelegationProposal(proposal1Cid), + )( + "Rejected proposal disappears from list", + _ => + aliceValidatorWalletClient + .listMintingDelegationProposals() + .proposals should have size 1, + ) + } + + // Test 3: Create a second proposal and test accept + clue("Test accept minting delegation proposal") { + val (_, proposal2Cid) = actAndCheck( + "Create minting delegation proposal", + createMintingDelegationProposal(beneficiaryParty, validatorParty, expiresAt), + )( + "Proposal is visible to validator", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + proposals.proposals + .find( + _.contract.payload.hcursor + .downField("delegation") + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + .contract + .contractId + }, + ) + + val (delegationCid, _) = actAndCheck( + "Validator accepts the proposal", + aliceValidatorWalletClient.acceptMintingDelegationProposal(proposal2Cid), + )( + "Proposal is archived and delegation is created", + delegationCid => { + aliceValidatorWalletClient + .listMintingDelegationProposals() + .proposals should have size 1 + val delegations = aliceValidatorWalletClient.listMintingDelegations() + delegations.delegations should have size 2 + delegationCid + }, + ) + } + + // Test 4: Create a new proposal and confirm that accepting it archives existing delegation + clue("Test accepting new proposal archives existing delegation") { + val (_, proposal3Cid) = actAndCheck( + "Create minting delegation proposal", + createMintingDelegationProposal(beneficiaryParty, validatorParty, expiresAt), + )( + "Proposal is visible to validator", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + proposals.proposals + .find( + _.contract.payload.hcursor + .downField("delegation") + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + .contract + .contractId + }, + ) + + val (newDelegationCid, _) = actAndCheck( + "Validator accepts new proposal", + aliceValidatorWalletClient.acceptMintingDelegationProposal(proposal3Cid), + )( + "Old delegation is archived, only the new delegation exists", + newDelegationCid => { + aliceValidatorWalletClient + .listMintingDelegationProposals() + .proposals should have size 1 + val delegations = aliceValidatorWalletClient.listMintingDelegations() + delegations.delegations should have size 2 + val beneficiaryDelegation = delegations.delegations + .find( + _.contract.payload.hcursor + .get[String]("beneficiary") + .contains(beneficiaryParty.party.toProtoPrimitive) + ) + .value + beneficiaryDelegation.contract.contractId shouldBe newDelegationCid + newDelegationCid + }, + ) + } + + // Test 4: Test auto-expiry of delegation and proposal + clue("Test expiry of MintingDelegation and MintingDelegationProposal") { + val expiresAtOneMin = env.environment.clock.now.plus(Duration.ofMinutes(1)).toInstant + + // Create a third beneficiary for expiry testing + val beneficiary3Party = + onboardExternalParty(aliceValidatorBackend, Some("beneficiary3")) + createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiary3Party) + + // Create proposal and accept it to create a delegation + val (_, proposalCidExpiry) = actAndCheck( + "Create minting delegation proposal with short expiry", + createMintingDelegationProposal(beneficiary3Party, validatorParty, expiresAtOneMin), + )( + "Proposal is visible", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + proposals.proposals + .find( + _.contract.payload.hcursor + .downField("delegation") + .get[String]("beneficiary") + .contains(beneficiary3Party.party.toProtoPrimitive) + ) + .value + .contract + .contractId + }, + ) + + actAndCheck( + "Accept proposal to create delegation with short expiry", + aliceValidatorWalletClient.acceptMintingDelegationProposal(proposalCidExpiry), + )( + "Delegation is created", + _ => { + val delegations = aliceValidatorWalletClient.listMintingDelegations() + delegations.delegations should have size 3 + }, + ) + + // Create another proposal and leave it unaccepted + actAndCheck( + "Create another proposal with short expiry", + createMintingDelegationProposal(beneficiary3Party, validatorParty, expiresAtOneMin), + )( + "Second proposal is visible", + _ => { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 2 + }, + ) + + // Advance time past expiry + advanceTime(Duration.ofMinutes(2)) + + clue("Expired delegation should be auto-rejected") { + eventually() { + val delegations = aliceValidatorWalletClient.listMintingDelegations().delegations + delegations.size shouldBe 2 + } + } + + clue("Expired proposal should be auto-rejected") { + eventually() { + val proposals = aliceValidatorWalletClient.listMintingDelegationProposals().proposals + proposals should have size 1 + } + } + } + } + } + + "MintingDelegationCollectRewardsTrigger" should { + "collect rewards for all coupons owned by the beneficiary" in { implicit env => + // This test verifies that MintingDelegationCollectRewardsTrigger collects + // ValidatorRewardCoupons, AppRewardCoupons, ValidatorLivenessActivityRecords, + // and UnclaimedActivityRecords. + + // Use alice (regular user) as the delegate + val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend) + aliceWalletClient.tap(100.0) + + // Validator also needs funds for the external party setup proposal + aliceValidatorWalletClient.tap(100.0) + + val beneficiaryParty = + onboardExternalParty(aliceValidatorBackend, Some("coupon_beneficiary")) + createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiaryParty) + + val expiresAt = env.environment.clock.now.plus(Duration.ofDays(30)).toInstant + val (_, proposalContractId) = actAndCheck( + "Create minting delegation proposal", + createMintingDelegationProposal(beneficiaryParty, aliceParty, expiresAt), + )( + "Proposal is visible", + _ => { + val proposals = aliceWalletClient.listMintingDelegationProposals() + proposals.proposals should have size 1 + proposals.proposals.head.contract.contractId + }, + ) + + // Alice accepts the proposal (not the validator) + actAndCheck( + "Alice accepts the proposal", + aliceWalletClient.acceptMintingDelegationProposal(proposalContractId), + )( + "Delegation is created", + _ => { + val delegations = aliceWalletClient.listMintingDelegations() + delegations.delegations should have size 1 + }, + ) + + val externalPartyWallet = eventually() { + aliceValidatorBackend.appState.walletManager + .valueOrFail("WalletManager is expected to be defined") + .externalPartyWalletManager + .lookupExternalPartyWallet(beneficiaryParty.party) + .valueOrFail( + s"Expected ${beneficiaryParty.party} to have an external party wallet" + ) + } + + def getBalance(): BigDecimal = BigDecimal( + aliceValidatorBackend + .getExternalPartyBalance(beneficiaryParty.party) + .totalUnlockedCoin + ) + + advanceRoundsToNextRoundOpening + advanceRoundsToNextRoundOpening + + // Get an issuing round whose opensAt is in the past. + val issuingRound = eventually() { + val (_, issuingRounds) = sv1ScanBackend.getOpenAndIssuingMiningRounds() + issuingRounds.toList.headOption.value.payload + } + + val balanceBefore = getBalance() + balanceBefore shouldBe BigDecimal(0) + + val appRewardAmount = BigDecimal(100.0) + val unclaimedActivityAmount = BigDecimal(200.0) + val validatorRewardAmount = BigDecimal(500.0) + val developmentFundAmount = BigDecimal(300.0) + + // For ValidatorRewardCoupon, we need ValidatorRight for beneficiary + aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJavaExternalOrLocal( + actingParty = beneficiaryParty.richPartyId, + commands = new ValidatorRight( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, // validator = beneficiary + ).create.commands.asScala.toSeq, + ) + + // Pause the validator's own reward collection trigger which would + // normally mint this coupon for itself, because the validator-app currently + // auto creates the ValidatorRight contract while onboarding the external-party + val validatorRewardTrigger = collectRewardsAndMergeAmuletsTrigger( + aliceValidatorBackend, + aliceValidatorWalletClient.config.ledgerApiUser, + ) + + setTriggersWithin(triggersToPauseAtStart = Seq(validatorRewardTrigger)) { + val externalPartyMintingDelegationTrigger = mintingDelegationCollectRewardsTrigger( + aliceValidatorBackend, + beneficiaryParty.party, + ) + + // Pause minting delegation trigger to ensure we mint them together + setTriggersWithin(triggersToPauseAtStart = Seq(externalPartyMintingDelegationTrigger)) { + // Create AppRewardCoupon + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + readAs = Seq.empty, + update = new AppRewardCoupon( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + false, + appRewardAmount.bigDecimal, + issuingRound.round, + java.util.Optional.empty(), + ).create, + ) + + // Create UnclaimedActivityRecord + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + readAs = Seq.empty, + update = new UnclaimedActivityRecord( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + unclaimedActivityAmount.bigDecimal, + "test reward", + env.environment.clock.now.plus(Duration.ofDays(1)).toInstant, + ).create, + ) + + // Create ValidatorLivenessActivityRecord + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + readAs = Seq.empty, + update = new ValidatorLivenessActivityRecord( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + issuingRound.round, + ).create, + ) + + // Create ValidatorRewardCoupon + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + readAs = Seq.empty, + update = new ValidatorRewardCoupon( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + validatorRewardAmount.bigDecimal, + issuingRound.round, + ).create, + ) + + // Create DevelopmentFundCoupon + sv1Backend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitWithResult( + userId = sv1Backend.config.ledgerApiUser, + actAs = Seq(dsoParty), + readAs = Seq.empty, + update = new DevelopmentFundCoupon( + dsoParty.toProtoPrimitive, + beneficiaryParty.party.toProtoPrimitive, + dsoParty.toProtoPrimitive, // fundManager = dso + developmentFundAmount.bigDecimal, + env.environment.clock.now.plus(Duration.ofDays(1)).toInstant, + "test development fund coupon", + ).create, + ) + } + + // Advance time to collect all rewards + advanceRoundsToNextRoundOpening + advanceTimeForRewardAutomationToRunForCurrentRound + + val (_, issuingRoundsAfter) = sv1ScanBackend.getOpenAndIssuingMiningRounds() + val issuingRoundsMap = issuingRoundsAfter.view.map(r => r.payload.round -> r.payload).toMap + + clue("All reward contracts should be consumed") { + eventually() { + externalPartyWallet.store.listUnclaimedActivityRecords().futureValue shouldBe empty + externalPartyWallet.store + .listSortedAppRewards(issuingRoundsMap) + .futureValue shouldBe empty + externalPartyWallet.store + .listSortedValidatorRewards(Some(issuingRoundsMap.keySet.map(_.number))) + .futureValue shouldBe empty + externalPartyWallet.store + .listSortedLivenessActivityRecords(issuingRoundsMap) + .futureValue shouldBe empty + externalPartyWallet.store + .listDevelopmentFundCoupons() + .futureValue shouldBe empty + } + } + } + + // Verify balance increase + val balanceAfter = getBalance() + val actualIncrease = balanceAfter - balanceBefore + + val expectedTotalReward = + (appRewardAmount * BigDecimal(issuingRound.issuancePerUnfeaturedAppRewardCoupon)) + + (BigDecimal( + issuingRound.optIssuancePerValidatorFaucetCoupon.orElse(java.math.BigDecimal.ZERO) + )) + + (validatorRewardAmount * BigDecimal(issuingRound.issuancePerValidatorRewardCoupon)) + + unclaimedActivityAmount + + developmentFundAmount + + actualIncrease shouldBe expectedTotalReward + + // Test merge behavior at 2x limit + def getAmuletCount() = { + externalPartyWallet.store.multiDomainAcsStore + .listContracts(Amulet.COMPANION, Limit.DefaultLimit) + .futureValue + .size + } + + clue("Test that amulets get merge at 2x limit") { + val currentCount = getAmuletCount() + val mergeLimit = DefaultAmuletMergeLimit + + // Transfer enough amulets to reach exactly 2x the merge limit + val amuletsNeededFor2x = (2 * mergeLimit) - currentCount + (1 to amuletsNeededFor2x).foreach { i => + aliceValidatorWalletClient.transferPreapprovalSend( + beneficiaryParty.party, + 10.0, + s"transfer-$i", + ) + } + + clue(s"Verify amulets merged to mergeLimit") { + eventually() { + val count = getAmuletCount() + count shouldBe mergeLimit + } + } + } + } + } + + private def collectRewardsAndMergeAmuletsTrigger( + validatorBackend: ValidatorAppBackendReference, + userName: String, + ): Trigger = + validatorBackend + .userWalletAutomation(userName) + .futureValue + .trigger[CollectRewardsAndMergeAmuletsTrigger] + + private def mintingDelegationCollectRewardsTrigger( + validatorBackend: ValidatorAppBackendReference, + externalParty: PartyId, + ): Trigger = + validatorBackend.appState.walletManager + .valueOrFail("WalletManager is expected to be defined") + .externalPartyWalletManager + .lookupExternalPartyWallet(externalParty) + .valueOrFail(s"Expected ${externalParty} to have an external party wallet") + .automation + .trigger[MintingDelegationCollectRewardsTrigger] + + private def createMintingDelegationProposal( + beneficiaryParty: OnboardingResult, + delegate: PartyId, + expiresAt: java.time.Instant, + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + createMintingDelegationProposalWithMergeLimit( + beneficiaryParty, + delegate, + expiresAt, + DefaultAmuletMergeLimit, + ) + } + + private def createMintingDelegationProposalWithMergeLimit( + beneficiaryParty: OnboardingResult, + delegate: PartyId, + expiresAt: java.time.Instant, + amuletMergeLimit: Int, + )(implicit env: SpliceTestConsoleEnvironment): Unit = { + val beneficiary = beneficiaryParty.party + val proposal = new mintingDelegationCodegen.MintingDelegationProposal( + new mintingDelegationCodegen.MintingDelegation( + beneficiary.toProtoPrimitive, + delegate.toProtoPrimitive, + dsoParty.toProtoPrimitive, + expiresAt, + amuletMergeLimit.toLong, + ) + ) + aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands + .submitJavaExternalOrLocal( + actingParty = beneficiaryParty.richPartyId, + commands = proposal.create.commands.asScala.toSeq, + ) + } +} diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala index 0295add734..7b8ad223b2 100644 --- a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/environment/DarResources.scala @@ -210,6 +210,7 @@ object DarResources { val splitwell_0_1_13 = DarResource("splitwell-0.1.13.dar") val splitwell_0_1_14 = DarResource("splitwell-0.1.14.dar") val splitwell_0_1_15 = DarResource("splitwell-0.1.15.dar") + val splitwell_0_1_16 = DarResource("splitwell-0.1.16.dar") val splitwell_current = DarResource("splitwell-current.dar") val splitwell = PackageResource( splitwell_current, @@ -231,6 +232,7 @@ object DarResources { splitwell_0_1_13, splitwell_0_1_14, splitwell_0_1_15, + splitwell_0_1_16, ), ) @@ -250,6 +252,7 @@ object DarResources { val wallet_0_1_13 = DarResource("splice-wallet-0.1.13.dar") val wallet_0_1_14 = DarResource("splice-wallet-0.1.14.dar") val wallet_0_1_15 = DarResource("splice-wallet-0.1.15.dar") + val wallet_0_1_16 = DarResource("splice-wallet-0.1.16.dar") val wallet_current = DarResource("splice-wallet-current.dar") val wallet = PackageResource( wallet_current, @@ -271,6 +274,7 @@ object DarResources { wallet_0_1_13, wallet_0_1_14, wallet_0_1_15, + wallet_0_1_16, ), ) diff --git a/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbTransferInputQueries.scala b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbTransferInputQueries.scala new file mode 100644 index 0000000000..30f758fe01 --- /dev/null +++ b/apps/common/src/main/scala/org/lfdecentralizedtrust/splice/store/db/DbTransferInputQueries.scala @@ -0,0 +1,88 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.store.db + +import com.daml.ledger.javaapi.data.codegen.ContractId +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.IssuingMiningRound +import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round +import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.ContractCompanion +import org.lfdecentralizedtrust.splice.store.db.AcsQueries.{AcsStoreId, SelectFromAcsTableResult} +import org.lfdecentralizedtrust.splice.store.{Limit, LimitHelpers, TransferInputStore} +import org.lfdecentralizedtrust.splice.util.{Contract, TemplateJsonDecoder} +import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture +import com.digitalasset.canton.lifecycle.CloseContext +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.resource.DbStorage.Implicits.BuilderChain.* +import com.digitalasset.canton.tracing.TraceContext +import slick.jdbc.canton.ActionBasedSQLInterpolation.Implicits.actionBasedSQLInterpolationCanton +import slick.jdbc.canton.SQLActionBuilder + +import scala.concurrent.{ExecutionContext, Future} + +/** TransferInput related DB queries + * + * The store's ACS table must have the index on columns: + * (store_id, migration_id, package_name, template_id_qualified_name, + * reward_coupon_round) WHERE (reward_coupon_round IS NOT NULL) + */ +trait DbTransferInputQueries extends AcsQueries with AcsTables with LimitHelpers { + self: TransferInputStore => + + protected def acsTableName: String + protected def acsStoreId: AcsStoreId + protected def domainMigrationId: Long + protected def dbStorage: DbStorage + + protected implicit def ec: ExecutionContext + protected implicit def closeContext: CloseContext + protected implicit def templateJsonDecoder: TemplateJsonDecoder + + // List reward coupons sorted by round and calculated value. + protected def listSortedRewardCoupons[C, TCid <: ContractId[?], T]( + companion: C, + issuingRoundsMap: Map[Round, IssuingMiningRound], + roundToIssuance: IssuingMiningRound => Option[BigDecimal], + limit: Limit, + ccValue: SQLActionBuilder = sql"rti.issuance", + )(implicit + companionClass: ContractCompanion[C, TCid, T], + tc: TraceContext, + ): Future[Seq[(Contract[TCid, T], BigDecimal)]] = { + val packageQualifiedName = companionClass.packageQualifiedName(companion) + issuingRoundsMap + .flatMap { case (round, contract) => + roundToIssuance(contract).map(round.number.longValue() -> _) + } + .map { case (round, issuance) => + sql"($round, $issuance)" + } + .reduceOption { (acc, next) => + (acc ++ sql"," ++ next).toActionBuilder + } match { + case None => Future.successful(Seq.empty) // no rounds = no results + case Some(roundToIssuanceValues) => + for { + result <- dbStorage.query( + (sql""" + with round_to_issuance(round, issuance) as (values """ ++ roundToIssuanceValues ++ sql""") + select + #${SelectFromAcsTableResult.sqlColumnsCommaSeparated()},""" ++ ccValue ++ sql""" + from #$acsTableName acs join round_to_issuance rti on acs.reward_coupon_round = rti.round + where acs.store_id = $acsStoreId + and migration_id = $domainMigrationId + and acs.package_name = ${packageQualifiedName.packageName} + and acs.template_id_qualified_name = ${packageQualifiedName.qualifiedName} + order by (acs.reward_coupon_round, -""" ++ ccValue ++ sql""") + limit ${sqlLimit(limit)}""").toActionBuilder + .as[(SelectFromAcsTableResult, BigDecimal)], + s"listSorted:$packageQualifiedName", + ) + } yield applyLimit(s"listSorted:$packageQualifiedName", limit, result).map { + case (row, issuance) => + val contract = contractFromRow(companion)(row) + contract -> issuance + } + } + } +} diff --git a/apps/package-lock.json b/apps/package-lock.json index 7bae29f88d..bcdf7a9518 100644 --- a/apps/package-lock.json +++ b/apps/package-lock.json @@ -31,9 +31,9 @@ "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.15", "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.21", "@daml.js/splice-validator-lifecycle": "file:common/frontend/daml.js/splice-validator-lifecycle-0.1.6", - "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.15", + "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.16", "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.15", - "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.15", + "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.16", "xunit-viewer": "^10.6.1" } }, @@ -743,6 +743,23 @@ "common/frontend/daml.js/splice-wallet-0.1.15": { "name": "@daml.js/splice-wallet-0.1.15", "version": "0.0.0", + "extraneous": true, + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.14": "file:../splice-amulet-0.1.14", + "@daml.js/splice-api-token-allocation-instruction-v1-1.0.0": "file:../splice-api-token-allocation-instruction-v1-1.0.0", + "@daml.js/splice-api-token-allocation-v1-1.0.0": "file:../splice-api-token-allocation-v1-1.0.0", + "@daml.js/splice-api-token-transfer-instruction-v1-1.0.0": "file:../splice-api-token-transfer-instruction-v1-1.0.0", + "@daml.js/splice-wallet-payments-0.1.14": "file:../splice-wallet-payments-0.1.14", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "common/frontend/daml.js/splice-wallet-0.1.16": { + "name": "@daml.js/splice-wallet-0.1.16", + "version": "0.0.0", "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -936,6 +953,20 @@ "common/frontend/daml.js/splitwell-0.1.15": { "name": "@daml.js/splitwell-0.1.15", "version": "0.0.0", + "extraneous": true, + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-amulet-0.1.14": "file:../splice-amulet-0.1.14", + "@daml.js/splice-wallet-payments-0.1.14": "file:../splice-wallet-payments-0.1.14", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "common/frontend/daml.js/splitwell-0.1.16": { + "name": "@daml.js/splitwell-0.1.16", + "version": "0.0.0", "license": "UNLICENSED", "dependencies": { "@daml.js/daml-prim-DA-Types-1.0.0": "file:../daml-prim-DA-Types-1.0.0", @@ -1491,7 +1522,7 @@ "link": true }, "node_modules/@daml.js/splice-wallet": { - "resolved": "common/frontend/daml.js/splice-wallet-0.1.15", + "resolved": "common/frontend/daml.js/splice-wallet-0.1.16", "link": true }, "node_modules/@daml.js/splice-wallet-payments": { @@ -1503,7 +1534,7 @@ "link": true }, "node_modules/@daml.js/splitwell": { - "resolved": "common/frontend/daml.js/splitwell-0.1.15", + "resolved": "common/frontend/daml.js/splitwell-0.1.16", "link": true }, "node_modules/@daml/ledger": { diff --git a/apps/package.json b/apps/package.json index 4c3df22b70..17340c27c8 100644 --- a/apps/package.json +++ b/apps/package.json @@ -26,9 +26,9 @@ "@daml.js/splice-amulet": "file:common/frontend/daml.js/splice-amulet-0.1.15", "@daml.js/splice-dso-governance": "file:common/frontend/daml.js/splice-dso-governance-0.1.21", "@daml.js/splice-validator-lifecycle": "file:common/frontend/daml.js/splice-validator-lifecycle-0.1.6", - "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.15", + "@daml.js/splice-wallet": "file:common/frontend/daml.js/splice-wallet-0.1.16", "@daml.js/splice-wallet-payments": "file:common/frontend/daml.js/splice-wallet-payments-0.1.15", - "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.15", + "@daml.js/splitwell": "file:common/frontend/daml.js/splitwell-0.1.16", "xunit-viewer": "^10.6.1" } } diff --git a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala index 3748a8ce9f..1453ae46de 100644 --- a/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala +++ b/apps/validator/src/main/scala/org/lfdecentralizedtrust/splice/validator/ValidatorApp.scala @@ -873,6 +873,7 @@ class ValidatorApp( config.ingestFromParticipantBegin, config.ingestUpdateHistoryFromParticipantBegin, config.parameters, + scanConnection, ) val walletManager = new UserWalletManager( ledgerClient, diff --git a/apps/wallet/src/main/openapi/wallet-internal.yaml b/apps/wallet/src/main/openapi/wallet-internal.yaml index 0095376581..40145afedf 100644 --- a/apps/wallet/src/main/openapi/wallet-internal.yaml +++ b/apps/wallet/src/main/openapi/wallet-internal.yaml @@ -871,6 +871,145 @@ paths: "500": $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + /v0/wallet/minting-delegation-proposals: + get: + tags: [wallet] + x-jvm-package: wallet + operationId: "listMintingDelegationProposals" + description: | + List all MintingDelegationProposal contracts where the user is the delegate. + parameters: + - name: "after" + description: | + A `next_page_token` from a prior response; if absent, return the first page. + in: "query" + required: false + schema: + type: integer + format: int64 + - name: "limit" + description: Maximum number of elements to return, 1000 by default. + in: "query" + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/ListMintingDelegationProposalsResponse" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + + /v0/wallet/minting-delegation-proposals/{contract_id}/accept: + post: + tags: [wallet] + x-jvm-package: wallet + operationId: "acceptMintingDelegationProposal" + description: | + As the delegate, accept a MintingDelegationProposal, creating a MintingDelegation contract. + If an existing MintingDelegation contract exists with the same beneficiary it will + be archived while accepting this proposal. + parameters: + - in: path + name: contract_id + required: true + schema: + type: string + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/AcceptMintingDelegationProposalResponse" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + + /v0/wallet/minting-delegation-proposals/{contract_id}/reject: + post: + tags: [wallet] + x-jvm-package: wallet + operationId: "rejectMintingDelegationProposal" + description: | + As the delegate, reject a MintingDelegationProposal. + parameters: + - in: path + name: contract_id + required: true + schema: + type: string + responses: + "200": + description: ok + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + + /v0/wallet/minting-delegations: + get: + tags: [wallet] + x-jvm-package: wallet + operationId: "listMintingDelegations" + description: | + List all MintingDelegation contracts where the user is the delegate. + parameters: + - name: "after" + description: | + A `next_page_token` from a prior response; if absent, return the first page. + in: "query" + required: false + schema: + type: integer + format: int64 + - name: "limit" + description: Maximum number of elements to return, 1000 by default. + in: "query" + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: ok + content: + application/json: + schema: + "$ref": "#/components/schemas/ListMintingDelegationsResponse" + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + + /v0/wallet/minting-delegations/{contract_id}/reject: + post: + tags: [wallet] + x-jvm-package: wallet + operationId: "rejectMintingDelegation" + description: | + As the delegate, reject/terminate a MintingDelegation contract. + parameters: + - in: path + name: contract_id + required: true + schema: + type: string + responses: + "200": + description: ok + "404": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/404" + "500": + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/responses/500" + components: schemas: @@ -1612,3 +1751,68 @@ components: type: object additionalProperties: type: string + + MintingDelegationProposalWithStatus: + type: object + required: + - contract + - beneficiary_onboarded + properties: + contract: + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + beneficiary_onboarded: + type: boolean + description: Whether the beneficiary party is currently onboarded + + ListMintingDelegationProposalsResponse: + type: object + required: + - proposals + properties: + proposals: + type: array + items: + $ref: "#/components/schemas/MintingDelegationProposalWithStatus" + next_page_token: + type: integer + format: int64 + description: | + When requesting the next page of results, pass this as URL query parameter `after`. + If absent or `null`, there are no more pages. + + AcceptMintingDelegationProposalResponse: + type: object + required: + - contract_id + properties: + contract_id: + type: string + description: Contract ID of the created MintingDelegation + + MintingDelegationWithStatus: + type: object + required: + - contract + - beneficiary_onboarded + properties: + contract: + $ref: "../../../../common/src/main/openapi/common-external.yaml#/components/schemas/Contract" + beneficiary_onboarded: + type: boolean + description: Whether the beneficiary party is currently onboarded + + ListMintingDelegationsResponse: + type: object + required: + - delegations + properties: + delegations: + type: array + items: + $ref: "#/components/schemas/MintingDelegationWithStatus" + next_page_token: + type: integer + format: int64 + description: | + When requesting the next page of results, pass this as URL query parameter `after`. + If absent or `null`, there are no more pages. diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala index e934a8164d..b81d01a2dd 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletManager.scala @@ -7,6 +7,7 @@ import org.apache.pekko.stream.Materializer import org.lfdecentralizedtrust.splice.config.{AutomationConfig, SpliceParametersConfig} import org.lfdecentralizedtrust.splice.environment.{RetryProvider, SpliceLedgerClient} import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -43,6 +44,7 @@ class ExternalPartyWalletManager( ingestFromParticipantBegin: Boolean, ingestUpdateHistoryFromParticipantBegin: Boolean, params: SpliceParametersConfig, + scanConnection: BftScanConnection, )(implicit ec: ExecutionContext, mat: Materializer, @@ -173,6 +175,7 @@ class ExternalPartyWalletManager( ingestFromParticipantBegin, ingestUpdateHistoryFromParticipantBegin, params, + scanConnection, ) (externalPartyRetryProvider, walletService) } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala index d0879bbad9..6c1d5b98a2 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/ExternalPartyWalletService.scala @@ -6,6 +6,7 @@ package org.lfdecentralizedtrust.splice.wallet import org.lfdecentralizedtrust.splice.config.{AutomationConfig, SpliceParametersConfig} import org.lfdecentralizedtrust.splice.environment.* import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -42,6 +43,7 @@ class ExternalPartyWalletService( ingestFromParticipantBegin: Boolean, ingestUpdateHistoryFromParticipantBegin: Boolean, params: SpliceParametersConfig, + scanConnection: BftScanConnection, )(implicit ec: ExecutionContext, mat: Materializer, @@ -89,6 +91,7 @@ class ExternalPartyWalletService( ingestFromParticipantBegin, ingestUpdateHistoryFromParticipantBegin, params, + scanConnection, loggerFactory, ) diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/api/client/commands/HttpWalletAppClient.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/api/client/commands/HttpWalletAppClient.scala index f01c00e7d7..169beb2736 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/api/client/commands/HttpWalletAppClient.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/api/client/commands/HttpWalletAppClient.scala @@ -1351,4 +1351,118 @@ object HttpWalletAppClient { } } + case class ListMintingDelegationProposals( + after: Option[Long] = None, + limit: Option[Int] = None, + ) extends InternalBaseCommand[ + http.ListMintingDelegationProposalsResponse, + definitions.ListMintingDelegationProposalsResponse, + ] { + def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[ + Future, + Either[Throwable, HttpResponse], + http.ListMintingDelegationProposalsResponse, + ] = + client.listMintingDelegationProposals(after, limit, headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.ListMintingDelegationProposalsResponse.OK(response) => + Right(response) + } + } + + case class AcceptMintingDelegationProposal(contractId: String) + extends InternalBaseCommand[ + http.AcceptMintingDelegationProposalResponse, + String, + ] { + def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[ + Future, + Either[Throwable, HttpResponse], + http.AcceptMintingDelegationProposalResponse, + ] = + client.acceptMintingDelegationProposal(contractId, headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.AcceptMintingDelegationProposalResponse.OK(response) => + Right(response.contractId) + } + } + + case class RejectMintingDelegationProposal(contractId: String) + extends InternalBaseCommand[ + http.RejectMintingDelegationProposalResponse, + Unit, + ] { + def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[ + Future, + Either[Throwable, HttpResponse], + http.RejectMintingDelegationProposalResponse, + ] = + client.rejectMintingDelegationProposal(contractId, headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.RejectMintingDelegationProposalResponse.OK => + Right(()) + } + } + + case class ListMintingDelegations( + after: Option[Long] = None, + limit: Option[Int] = None, + ) extends InternalBaseCommand[ + http.ListMintingDelegationsResponse, + definitions.ListMintingDelegationsResponse, + ] { + def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[ + Future, + Either[Throwable, HttpResponse], + http.ListMintingDelegationsResponse, + ] = + client.listMintingDelegations(after, limit, headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.ListMintingDelegationsResponse.OK(response) => + Right(response) + } + } + + case class RejectMintingDelegation(contractId: String) + extends InternalBaseCommand[ + http.RejectMintingDelegationResponse, + Unit, + ] { + def submitRequest( + client: Client, + headers: List[HttpHeader], + ): EitherT[ + Future, + Either[Throwable, HttpResponse], + http.RejectMintingDelegationResponse, + ] = + client.rejectMintingDelegation(contractId, headers = headers) + + override def handleOk()(implicit + decoder: TemplateJsonDecoder + ) = { case http.RejectMintingDelegationResponse.OK => + Right(()) + } + } + } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala index 2bb7b46fc6..3224843a86 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/admin/http/HttpWalletHandler.scala @@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.install.{ } import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.{ install as installCodegen, + mintingdelegation as mintingDelegationCodegen, payment as walletCodegen, subscriptions as subsCodegen, transferoffer as transferOffersCodegen, @@ -1278,4 +1279,204 @@ class HttpWalletHandler( ) } } + + override def listMintingDelegationProposals( + respond: WalletResource.ListMintingDelegationProposalsResponse.type + )(after: Option[Long], limit: Option[Int])( + tuser: WalletUserRequest + ): Future[WalletResource.ListMintingDelegationProposalsResponse] = { + implicit val WalletUserRequest(user, userWallet, traceContext) = tuser + withSpan(s"$workflowId.listMintingDelegationProposals") { _ => _ => + val pageLimit = PageLimit.tryCreate(limit.getOrElse(Limit.DefaultMaxPageSize)) + for { + page <- userWallet.store.listMintingDelegationProposals(after, pageLimit) + } yield { + val proposalsWithStatus = page.resultsInPage.map { proposal => + val beneficiary = PartyId.tryFromProtoPrimitive(proposal.payload.delegation.beneficiary) + val isOnboarded = + walletManager.externalPartyWalletManager + .lookupExternalPartyWallet(beneficiary) + .isDefined + d0.MintingDelegationProposalWithStatus(proposal.toHttp, isOnboarded) + }.toVector + WalletResource.ListMintingDelegationProposalsResponseOK( + d0.ListMintingDelegationProposalsResponse( + proposalsWithStatus, + page.nextPageToken, + ) + ) + } + } + } + + // Accepts a MintingDelegationProposal, atomically archiving (one) existing delegation from the + // same beneficiary if present. + override def acceptMintingDelegationProposal( + respond: WalletResource.AcceptMintingDelegationProposalResponse.type + )(contractId: String)( + tuser: WalletUserRequest + ): Future[WalletResource.AcceptMintingDelegationProposalResponse] = { + implicit val WalletUserRequest(user, userWallet, traceContext) = tuser + withSpan(s"$workflowId.acceptMintingDelegationProposal") { implicit traceContext => _ => + val proposalCid = Codec.tryDecodeJavaContractId( + mintingDelegationCodegen.MintingDelegationProposal.COMPANION + )(contractId) + val store = userWallet.store + for { + // Accept the proposal while archiving existing delegation + newDelegationCid <- retryProvider.retryForClientCalls( + "accept_minting_delegation_proposal", + "Accept minting delegation proposal", + for { + proposal <- store.multiDomainAcsStore + .getContractById(mintingDelegationCodegen.MintingDelegationProposal.COMPANION)( + proposalCid + ) + .map( + _.toAssignedContract.getOrElse( + throw Status.Code.FAILED_PRECONDITION.toStatus + .withDescription( + s"MintingDelegationProposal is not assigned to a synchronizer." + ) + .asRuntimeException() + ) + ) + beneficiary = proposal.payload.delegation.beneficiary + existingDelegations <- store.listMintingDelegations(None, PageLimit.tryCreate(100)) + sameBeneficiaryDelegations = existingDelegations.resultsInPage.toList.filter( + _.payload.beneficiary == beneficiary + ) + existingDelegationCidOpt = sameBeneficiaryDelegations.headOption + .map(_.contractId) + .toJava + result <- userWallet.connection + .submit( + Seq(store.key.endUserParty), + Seq.empty, + proposal.exercise( + _.exerciseMintingDelegationProposal_Accept(existingDelegationCidOpt) + ), + ) + .noDedup + .withSynchronizerId(proposal.domain) + .yieldResult() + } yield result.exerciseResult.mintingDelegationCid, + logger, + ) + } yield WalletResource.AcceptMintingDelegationProposalResponseOK( + d0.AcceptMintingDelegationProposalResponse( + Codec.encodeContractId(newDelegationCid) + ) + ) + } + } + + override def rejectMintingDelegationProposal( + respond: WalletResource.RejectMintingDelegationProposalResponse.type + )(contractId: String)( + tuser: WalletUserRequest + ): Future[WalletResource.RejectMintingDelegationProposalResponse] = { + implicit val WalletUserRequest(user, userWallet, traceContext) = tuser + withSpan(s"$workflowId.rejectMintingDelegationProposal") { implicit traceContext => _ => + val proposalCid = Codec.tryDecodeJavaContractId( + mintingDelegationCodegen.MintingDelegationProposal.COMPANION + )(contractId) + val store = userWallet.store + retryProvider.retryForClientCalls( + "reject_minting_delegation_proposal", + "Reject minting delegation proposal", + for { + proposal <- store.multiDomainAcsStore + .getContractById(mintingDelegationCodegen.MintingDelegationProposal.COMPANION)( + proposalCid + ) + .map( + _.toAssignedContract.getOrElse( + throw Status.Code.FAILED_PRECONDITION.toStatus + .withDescription(s"MintingDelegationProposal is not assigned to a synchronizer.") + .asRuntimeException() + ) + ) + _ <- userWallet.connection + .submit( + Seq(store.key.endUserParty), + Seq.empty, + proposal.exercise(_.exerciseMintingDelegationProposal_Reject()), + ) + .noDedup + .withSynchronizerId(proposal.domain) + .yieldUnit() + } yield WalletResource.RejectMintingDelegationProposalResponseOK, + logger, + ) + } + } + + override def listMintingDelegations( + respond: WalletResource.ListMintingDelegationsResponse.type + )(after: Option[Long], limit: Option[Int])( + tuser: WalletUserRequest + ): Future[WalletResource.ListMintingDelegationsResponse] = { + implicit val WalletUserRequest(user, userWallet, traceContext) = tuser + withSpan(s"$workflowId.listMintingDelegations") { _ => _ => + val pageLimit = PageLimit.tryCreate(limit.getOrElse(Limit.DefaultMaxPageSize)) + for { + page <- userWallet.store.listMintingDelegations(after, pageLimit) + } yield { + val delegationsWithStatus = page.resultsInPage.map { delegation => + val beneficiary = PartyId.tryFromProtoPrimitive(delegation.payload.beneficiary) + val isOnboarded = + walletManager.externalPartyWalletManager + .lookupExternalPartyWallet(beneficiary) + .isDefined + d0.MintingDelegationWithStatus(delegation.toHttp, isOnboarded) + }.toVector + WalletResource.ListMintingDelegationsResponseOK( + d0.ListMintingDelegationsResponse( + delegationsWithStatus, + page.nextPageToken, + ) + ) + } + } + } + + override def rejectMintingDelegation( + respond: WalletResource.RejectMintingDelegationResponse.type + )(contractId: String)( + tuser: WalletUserRequest + ): Future[WalletResource.RejectMintingDelegationResponse] = { + implicit val WalletUserRequest(user, userWallet, traceContext) = tuser + withSpan(s"$workflowId.rejectMintingDelegation") { implicit traceContext => _ => + val delegationCid = Codec.tryDecodeJavaContractId( + mintingDelegationCodegen.MintingDelegation.COMPANION + )(contractId) + val store = userWallet.store + retryProvider.retryForClientCalls( + "reject_minting_delegation", + "Reject minting delegation", + for { + delegation <- store.multiDomainAcsStore + .getContractById(mintingDelegationCodegen.MintingDelegation.COMPANION)(delegationCid) + .map( + _.toAssignedContract.getOrElse( + throw Status.Code.FAILED_PRECONDITION.toStatus + .withDescription(s"MintingDelegation is not assigned to a synchronizer.") + .asRuntimeException() + ) + ) + _ <- userWallet.connection + .submit( + Seq(store.key.endUserParty), + Seq.empty, + delegation.exercise(_.exerciseMintingDelegation_Reject()), + ) + .noDedup + .withSynchronizerId(delegation.domain) + .yieldUnit() + } yield WalletResource.RejectMintingDelegationResponseOK, + logger, + ) + } + } } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationProposalTrigger.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationProposalTrigger.scala new file mode 100644 index 0000000000..6d96598d60 --- /dev/null +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationProposalTrigger.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.wallet.automation + +import org.lfdecentralizedtrust.splice.automation.{ + MultiDomainExpiredContractTrigger, + ScheduledTaskTrigger, + TaskOutcome, + TaskSuccess, + TriggerContext, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation as mintingDelegationCodegen +import org.lfdecentralizedtrust.splice.environment.SpliceLedgerConnection +import org.lfdecentralizedtrust.splice.util.AssignedContract +import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer +import org.apache.pekko.stream.Materializer + +import scala.concurrent.{ExecutionContext, Future} + +// Auto-rejects expired MintingDelegationProposal contracts. +class ExpireMintingDelegationProposalTrigger( + override protected val context: TriggerContext, + store: UserWalletStore, + connection: SpliceLedgerConnection, +)(implicit + ec: ExecutionContext, + mat: Materializer, + tracer: Tracer, +) extends MultiDomainExpiredContractTrigger.Template[ + mintingDelegationCodegen.MintingDelegationProposal.ContractId, + mintingDelegationCodegen.MintingDelegationProposal, + ]( + store.multiDomainAcsStore, + store.listExpiredMintingDelegationProposals, + mintingDelegationCodegen.MintingDelegationProposal.COMPANION, + ) { + + override protected def extraMetricLabels: Seq[(String, String)] = + Seq("party" -> store.key.endUserParty.toString) + + override protected def completeTask( + task: ScheduledTaskTrigger.ReadyTask[ + AssignedContract[ + mintingDelegationCodegen.MintingDelegationProposal.ContractId, + mintingDelegationCodegen.MintingDelegationProposal, + ] + ] + )(implicit tc: TraceContext): Future[TaskOutcome] = { + val beneficiary = task.work.payload.delegation.beneficiary + connection + .submit( + actAs = Seq(store.key.endUserParty), + readAs = Seq.empty, + update = task.work.exercise(_.exerciseMintingDelegationProposal_Reject()), + ) + .noDedup + .yieldUnit() + .map(_ => + TaskSuccess( + s"Rejected expired MintingDelegationProposal for beneficiary $beneficiary" + ) + ) + } +} diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationTrigger.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationTrigger.scala new file mode 100644 index 0000000000..5e66216b1c --- /dev/null +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExpireMintingDelegationTrigger.scala @@ -0,0 +1,67 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.wallet.automation + +import org.lfdecentralizedtrust.splice.automation.{ + MultiDomainExpiredContractTrigger, + ScheduledTaskTrigger, + TaskOutcome, + TaskSuccess, + TriggerContext, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation as mintingDelegationCodegen +import org.lfdecentralizedtrust.splice.environment.SpliceLedgerConnection +import org.lfdecentralizedtrust.splice.util.AssignedContract +import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer +import org.apache.pekko.stream.Materializer + +import scala.concurrent.{ExecutionContext, Future} + +// Auto-rejects expired MintingDelegation contracts. +class ExpireMintingDelegationTrigger( + override protected val context: TriggerContext, + store: UserWalletStore, + connection: SpliceLedgerConnection, +)(implicit + ec: ExecutionContext, + mat: Materializer, + tracer: Tracer, +) extends MultiDomainExpiredContractTrigger.Template[ + mintingDelegationCodegen.MintingDelegation.ContractId, + mintingDelegationCodegen.MintingDelegation, + ]( + store.multiDomainAcsStore, + store.listExpiredMintingDelegations, + mintingDelegationCodegen.MintingDelegation.COMPANION, + ) { + + override protected def extraMetricLabels: Seq[(String, String)] = + Seq("party" -> store.key.endUserParty.toString) + + override protected def completeTask( + task: ScheduledTaskTrigger.ReadyTask[ + AssignedContract[ + mintingDelegationCodegen.MintingDelegation.ContractId, + mintingDelegationCodegen.MintingDelegation, + ] + ] + )(implicit tc: TraceContext): Future[TaskOutcome] = { + val beneficiary = task.work.payload.beneficiary + connection + .submit( + actAs = Seq(store.key.endUserParty), + readAs = Seq.empty, + update = task.work.exercise(_.exerciseMintingDelegation_Reject()), + ) + .noDedup + .yieldUnit() + .map(_ => + TaskSuccess( + s"Rejected expired MintingDelegation for beneficiary $beneficiary" + ) + ) + } +} diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExternalPartyWalletAutomationService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExternalPartyWalletAutomationService.scala index d5f01b86f9..8ebaecb273 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExternalPartyWalletAutomationService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/ExternalPartyWalletAutomationService.scala @@ -8,8 +8,10 @@ import org.lfdecentralizedtrust.splice.automation.{ SpliceAppAutomationService, } import AutomationServiceCompanion.TriggerClass +import org.lfdecentralizedtrust.splice.store.AppStoreWithIngestion.SpliceLedgerConnectionPriority import org.lfdecentralizedtrust.splice.config.{AutomationConfig, SpliceParametersConfig} import org.lfdecentralizedtrust.splice.environment.* +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection import org.lfdecentralizedtrust.splice.store.{ DomainTimeSynchronization, DomainUnpausedSynchronization, @@ -35,6 +37,7 @@ class ExternalPartyWalletAutomationService( ingestFromParticipantBegin: Boolean, ingestUpdateHistoryFromParticipantBegin: Boolean, params: SpliceParametersConfig, + scanConnection: BftScanConnection, override protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext, @@ -59,6 +62,15 @@ class ExternalPartyWalletAutomationService( updateHistory, ingestUpdateHistoryFromParticipantBegin, ) + + registerTrigger( + new MintingDelegationCollectRewardsTrigger( + triggerContext, + store, + scanConnection, + connection(SpliceLedgerConnectionPriority.Low), + ) + ) } object ExternalPartyWalletAutomationService extends AutomationServiceCompanion { diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala new file mode 100644 index 0000000000..7865385278 --- /dev/null +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/MintingDelegationCollectRewardsTrigger.scala @@ -0,0 +1,364 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.wallet.automation + +import org.lfdecentralizedtrust.splice.automation.{PollingTrigger, TriggerContext} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.{ + PaymentTransferContext, + TransferContext, + TransferInput, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.transferinput.{ + InputAmulet, + InputAppRewardCoupon, + InputDevelopmentFundCoupon, + InputUnclaimedActivityRecord, + InputValidatorLivenessActivityRecord, + InputValidatorRewardCoupon, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{ + AppRewardCoupon, + Amulet, + DevelopmentFundCoupon, + UnclaimedActivityRecord, + ValidatorRewardCoupon, + ValidatorRight, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.AmuletRules +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.{ + IssuingMiningRound, + OpenMiningRound, +} +import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round +import org.lfdecentralizedtrust.splice.codegen.java.splice.validatorlicense.ValidatorLivenessActivityRecord +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation.MintingDelegation +import org.lfdecentralizedtrust.splice.environment.{RetryFor, SpliceLedgerConnection} +import org.lfdecentralizedtrust.splice.scan.admin.api.client.BftScanConnection +import org.lfdecentralizedtrust.splice.store.Limit +import org.lfdecentralizedtrust.splice.util.{ + AssignedContract, + Contract, + ContractWithState, + SpliceUtil, +} +import org.lfdecentralizedtrust.splice.wallet.store.ExternalPartyWalletStore +import com.digitalasset.canton.topology.PartyId +import com.digitalasset.canton.tracing.TraceContext +import io.opentelemetry.api.trace.Tracer + +import org.apache.pekko.stream.Materializer +import scala.concurrent.{ExecutionContext, Future} +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* + +// Although this trigger is part of external-party automation +// The work performed here is done as the delegate of the MintingDelegation contract +class MintingDelegationCollectRewardsTrigger( + override protected val context: TriggerContext, + store: ExternalPartyWalletStore, + scanConnection: BftScanConnection, + spliceLedgerConnection: SpliceLedgerConnection, +)(implicit + override val ec: ExecutionContext, + override val tracer: Tracer, + materializer: Materializer, +) extends PollingTrigger { + + private def externalParty = store.key.externalParty + + override protected def extraMetricLabels = Seq("party" -> externalParty.toString) + + override def isRewardOperationTrigger: Boolean = true + + override def performWorkIfAvailable()(implicit tc: TraceContext): Future[Boolean] = { + context.retryProvider.retry( + RetryFor.Automation, + "collect_rewards_as_delegate", + "Collect rewards as delegate for the minting delegation", + collectRewardsAsDelegate(), + logger, + ) + } + + private def collectRewardsAsDelegate()(implicit tc: TraceContext): Future[Boolean] = { + for { + delegations <- store.multiDomainAcsStore.listContracts( + MintingDelegation.COMPANION, + Limit.DefaultLimit, + ) + + // In the steady state there is at most one active delegation per beneficiary. + // Thus if there are multiple ones, we can just pick one of them. + result <- delegations.flatMap(_.toAssignedContract).headOption match { + case Some(delegation) => processDelegation(delegation) + case None => Future.successful(false) + } + } yield result + } + + private def processDelegation( + assignedDelegation: AssignedContract[MintingDelegation.ContractId, MintingDelegation] + )(implicit tc: TraceContext): Future[Boolean] = { + val delegation = assignedDelegation.contract + val delegateParty = PartyId.tryFromProtoPrimitive(delegation.payload.delegate) + + // Confirm that delegate is an active local party, else ignore + spliceLedgerConnection.getParty(delegateParty).flatMap { + case Some(partyDetails) if partyDetails.isLocal => + val now = context.clock.now.toInstant + if (delegation.payload.expiresAt.isBefore(now)) { + logger.info( + s"Skipping reward collection for expired minting delegation (expired at ${delegation.payload.expiresAt})" + ) + Future.successful(false) + } else { + for { + (openRound, openIssuingRounds, issuingRoundsMap, amuletRules) <- fetchDataFromScan() + couponsData <- fetchCouponsData(issuingRoundsMap) + amulets <- store.listAmulets() + validatorRightOpt <- store.lookupValidatorRight() + result <- performMintIfNeeded( + delegation, + openRound, + openIssuingRounds, + amuletRules, + couponsData, + amulets, + validatorRightOpt, + ) + } yield result + } + case _ => + Future.successful(false) + } + } + + private def performMintIfNeeded( + delegation: Contract[ + MintingDelegation.ContractId, + MintingDelegation, + ], + openRound: ContractWithState[OpenMiningRound.ContractId, OpenMiningRound], + openIssuingRounds: Seq[ContractWithState[IssuingMiningRound.ContractId, IssuingMiningRound]], + amuletRules: ContractWithState[AmuletRules.ContractId, AmuletRules], + couponsData: CouponsData, + amulets: Seq[Contract[ + Amulet.ContractId, + Amulet, + ]], + validatorRightOpt: Option[Contract[ValidatorRight.ContractId, ValidatorRight]], + )(implicit tc: TraceContext): Future[Boolean] = { + val mergeLimit = delegation.payload.amuletMergeLimit.longValue() + val maxNumInputs = openRound.payload.transferConfigUsd.maxNumInputs.intValue() + // Ignore ValidatorRewardCoupons if we don't have the ValidatorRight to collect them as beneficiary + val validatorRewardCouponsToCollect = + if (validatorRightOpt.isDefined) couponsData.validatorRewardCoupons else Seq.empty + val hasRewardsToCollect = couponsData.livenessActivityRecords.nonEmpty || + validatorRewardCouponsToCollect.nonEmpty || + couponsData.appRewardCoupons.nonEmpty || + couponsData.unclaimedActivityRecords.nonEmpty || + couponsData.developmentFundCoupons.nonEmpty + // Merge amulets only if we're above 2x the merge limit to reduce potential waste of traffic + val shouldMergeAmulets = amulets.size >= 2 * mergeLimit + + if (hasRewardsToCollect || shouldMergeAmulets) { + val amuletsToMerge = if (shouldMergeAmulets) { + // Merge the smallest amounts first + // we do +1 here to maintain exactly 'mergeLimit' amulets after the mint + amulets + .sortBy(a => + BigDecimal(SpliceUtil.currentAmount(a.payload, openRound.payload.round.number)) + ) + .take(amulets.size - mergeLimit.toInt + 1) + } else Seq.empty + + // Use filtered couponsData with only collectable ValidatorRewardCoupons + val filteredCouponsData = couponsData.copy( + validatorRewardCoupons = validatorRewardCouponsToCollect + ) + + val inputs = buildTransferInputs(filteredCouponsData, amuletsToMerge, maxNumInputs) + val transferContext = + buildTransferContext(openRound, openIssuingRounds, filteredCouponsData, validatorRightOpt) + val paymentContext = new PaymentTransferContext( + amuletRules.contractId, + transferContext, + ) + + val contractsToDisclose = spliceLedgerConnection.disclosedContracts( + amuletRules, + openRound, + ) addAll openIssuingRounds + + val delegateParty = PartyId.tryFromProtoPrimitive(delegation.payload.delegate) + + spliceLedgerConnection + .submit( + actAs = Seq(delegateParty), + readAs = Seq(delegateParty, externalParty), + delegation.contractId.exerciseMintingDelegation_Mint(inputs.asJava, paymentContext), + ) + .withDisclosedContracts(contractsToDisclose) + .noDedup + .yieldUnit() + .map { _ => + logger.debug( + s"Collected ${filteredCouponsData.livenessActivityRecords.size} liveness activity records, " + + s"${filteredCouponsData.validatorRewardCoupons.size} validator reward coupons, " + + s"${filteredCouponsData.appRewardCoupons.size} app reward coupons, " + + s"${filteredCouponsData.unclaimedActivityRecords.size} unclaimed activity records, " + + s"${filteredCouponsData.developmentFundCoupons.size} development fund coupons, " + + s"and merged ${amuletsToMerge.size} amulets for delegation ${delegation.contractId}" + ) + true + } + } else { + Future.successful(false) + } + } + + // Helper APIs + private def fetchDataFromScan()(implicit tc: TraceContext): Future[ + ( + ContractWithState[OpenMiningRound.ContractId, OpenMiningRound], + Seq[ContractWithState[IssuingMiningRound.ContractId, IssuingMiningRound]], + Map[Round, IssuingMiningRound], + ContractWithState[AmuletRules.ContractId, AmuletRules], + ) + ] = { + for { + (openRounds, issuingRounds) <- scanConnection.getOpenAndIssuingMiningRounds() + amuletRules <- scanConnection.getAmuletRulesWithState() + } yield { + val now = context.clock.now + val openRound = SpliceUtil.selectLatestOpenMiningRound(now, openRounds) + val openIssuingRounds = issuingRounds.filter(c => c.payload.opensAt.isBefore(now.toInstant)) + val issuingRoundsMap = openIssuingRounds.view.map { r => + val imr = r.payload + (imr.round, imr) + }.toMap + (openRound, openIssuingRounds, issuingRoundsMap, amuletRules) + } + } + + private case class CouponsData( + livenessActivityRecords: Seq[Contract[ + ValidatorLivenessActivityRecord.ContractId, + ValidatorLivenessActivityRecord, + ]], + validatorRewardCoupons: Seq[Contract[ + ValidatorRewardCoupon.ContractId, + ValidatorRewardCoupon, + ]], + appRewardCoupons: Seq[Contract[ + AppRewardCoupon.ContractId, + AppRewardCoupon, + ]], + unclaimedActivityRecords: Seq[Contract[ + UnclaimedActivityRecord.ContractId, + UnclaimedActivityRecord, + ]], + developmentFundCoupons: Seq[Contract[ + DevelopmentFundCoupon.ContractId, + DevelopmentFundCoupon, + ]], + ) + + private def fetchCouponsData( + issuingRoundsMap: Map[ + Round, + IssuingMiningRound, + ] + )(implicit tc: TraceContext): Future[CouponsData] = { + for { + livenessActivityRecordsWithQuantity <- store.listSortedLivenessActivityRecords( + issuingRoundsMap + ) + validatorRewardCoupons <- store.listSortedValidatorRewards( + Some(issuingRoundsMap.keySet.map(_.number)) + ) + appRewardCouponsWithQuantity <- store.listSortedAppRewards(issuingRoundsMap) + unclaimedActivityRecords <- store.listUnclaimedActivityRecords() + developmentFundCoupons <- store.listDevelopmentFundCoupons() + } yield CouponsData( + livenessActivityRecordsWithQuantity.map(_._1), + validatorRewardCoupons, + appRewardCouponsWithQuantity.map(_._1), + unclaimedActivityRecords, + developmentFundCoupons, + ) + } + + private def buildTransferInputs( + couponsData: CouponsData, + amuletsToMerge: Seq[Contract[ + Amulet.ContractId, + Amulet, + ]], + maxNumInputs: Int, + ): Seq[TransferInput] = { + val livenessInputs: Seq[TransferInput] = couponsData.livenessActivityRecords.map { record => + new InputValidatorLivenessActivityRecord(record.contractId): TransferInput + } + + val validatorCouponInputs: Seq[TransferInput] = couponsData.validatorRewardCoupons.map { + coupon => + new InputValidatorRewardCoupon(coupon.contractId): TransferInput + } + + val appCouponInputs: Seq[TransferInput] = couponsData.appRewardCoupons.map { coupon => + new InputAppRewardCoupon(coupon.contractId): TransferInput + } + + val unclaimedActivityRecordInputs: Seq[TransferInput] = + couponsData.unclaimedActivityRecords.map { record => + new InputUnclaimedActivityRecord(record.contractId): TransferInput + } + + val developmentFundCouponInputs: Seq[TransferInput] = + couponsData.developmentFundCoupons.map { coupon => + new InputDevelopmentFundCoupon(coupon.contractId): TransferInput + } + + val amuletInputs: Seq[TransferInput] = amuletsToMerge.map { amulet => + new InputAmulet(amulet.contractId): TransferInput + } + + val allInputs = livenessInputs ++ validatorCouponInputs ++ appCouponInputs ++ + unclaimedActivityRecordInputs ++ developmentFundCouponInputs ++ amuletInputs + allInputs.take(maxNumInputs) + } + + private def buildTransferContext( + openRound: ContractWithState[OpenMiningRound.ContractId, OpenMiningRound], + openIssuingRounds: Seq[ContractWithState[IssuingMiningRound.ContractId, IssuingMiningRound]], + couponsData: CouponsData, + validatorRightOpt: Option[Contract[ValidatorRight.ContractId, ValidatorRight]], + ): TransferContext = { + // Only include ValidatorRight in context if we're actually collecting ValidatorRewardCoupons + val validatorRightsMap = + (validatorRightOpt, couponsData.validatorRewardCoupons.nonEmpty) match { + case (Some(vr), true) => Map(vr.payload.user -> vr.contractId) + case _ => Map.empty[String, ValidatorRight.ContractId] + } + + new TransferContext( + openRound.contractId, + openIssuingRounds.view + .filter(r => + couponsData.livenessActivityRecords.exists(_.payload.round == r.payload.round) || + couponsData.validatorRewardCoupons.exists(_.payload.round == r.payload.round) || + couponsData.appRewardCoupons.exists(_.payload.round == r.payload.round) + ) + .map(r => (r.payload.round, r.contractId)) + .toMap[ + Round, + IssuingMiningRound.ContractId, + ] + .asJava, + validatorRightsMap.asJava, + None.toJava, + ) + } +} diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala index 02120735e6..57b92df34e 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/automation/UserWalletAutomationService.scala @@ -193,6 +193,22 @@ class UserWalletAutomationService( ) ) } + + registerTrigger( + new ExpireMintingDelegationTrigger( + triggerContext, + store, + connection(SpliceLedgerConnectionPriority.Low), + ) + ) + + registerTrigger( + new ExpireMintingDelegationProposalTrigger( + triggerContext, + store, + connection(SpliceLedgerConnectionPriority.Low), + ) + ) } object UserWalletAutomationService extends AutomationServiceCompanion { @@ -216,5 +232,7 @@ object UserWalletAutomationService extends AutomationServiceCompanion { aTrigger[AutoAcceptTransferOffersTrigger], aTrigger[AmuletMetricsTrigger], aTrigger[TxLogBackfillingTrigger[TxLogEntry]], + aTrigger[ExpireMintingDelegationTrigger], + aTrigger[ExpireMintingDelegationProposalTrigger], ) } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/ExternalPartyWalletStore.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/ExternalPartyWalletStore.scala index 6e0927f798..21187133cd 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/ExternalPartyWalletStore.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/ExternalPartyWalletStore.scala @@ -6,10 +6,17 @@ package org.lfdecentralizedtrust.splice.wallet.store import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.{ Amulet, AppRewardCoupon, + DevelopmentFundCoupon, LockedAmulet, + UnclaimedActivityRecord, ValidatorRewardCoupon, + ValidatorRight, } import org.lfdecentralizedtrust.splice.codegen.java.splice.externalpartyamuletrules.TransferCommandCounter +import org.lfdecentralizedtrust.splice.codegen.java.splice.validatorlicense as validatorCodegen +import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation as mintingDelegationCodegen +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.IssuingMiningRound +import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.* @@ -50,6 +57,60 @@ trait ExternalPartyWalletStore extends TransferInputStore with NamedLogging { multiDomainAcsStore .findAnyContractWithOffset(TransferCommandCounter.COMPANION) .map(_.value.map(_.contract)) + + def listMintingDelegations(limit: Limit = Limit.DefaultLimit)(implicit + tc: TraceContext + ): Future[Seq[Contract[ + mintingDelegationCodegen.MintingDelegation.ContractId, + mintingDelegationCodegen.MintingDelegation, + ]]] = + multiDomainAcsStore + .listContracts(mintingDelegationCodegen.MintingDelegation.COMPANION, limit) + .map(_.map(_.contract)) + + def listSortedLivenessActivityRecords( + issuingRoundsMap: Map[Round, IssuingMiningRound], + limit: Limit = Limit.DefaultLimit, + )(implicit tc: TraceContext): Future[Seq[ + ( + Contract[ + validatorCodegen.ValidatorLivenessActivityRecord.ContractId, + validatorCodegen.ValidatorLivenessActivityRecord, + ], + BigDecimal, + ) + ]] + + def listUnclaimedActivityRecords( + limit: Limit = Limit.DefaultLimit + )(implicit tc: TraceContext): Future[Seq[ + Contract[ + UnclaimedActivityRecord.ContractId, + UnclaimedActivityRecord, + ] + ]] = + multiDomainAcsStore + .listContracts(UnclaimedActivityRecord.COMPANION, limit) + .map(_.map(_.contract)) + + def listDevelopmentFundCoupons( + limit: Limit = Limit.DefaultLimit + )(implicit tc: TraceContext): Future[Seq[ + Contract[ + DevelopmentFundCoupon.ContractId, + DevelopmentFundCoupon, + ] + ]] = + multiDomainAcsStore + .listContracts(DevelopmentFundCoupon.COMPANION, limit) + .map(_.map(_.contract)) + + def lookupValidatorRight()(implicit + tc: TraceContext + ): Future[Option[Contract[ValidatorRight.ContractId, ValidatorRight]]] = + multiDomainAcsStore + .findAnyContractWithOffset(ValidatorRight.COMPANION) + .map(_.value.map(_.contract)) } object ExternalPartyWalletStore { @@ -131,6 +192,33 @@ object ExternalPartyWalletStore { co.payload.dso == dso && co.payload.sender == externalParty }(ExternalPartyWalletAcsStoreRowData(_)), + mkFilter(mintingDelegationCodegen.MintingDelegation.COMPANION) { co => + co.payload.dso == dso && + co.payload.beneficiary == externalParty + }(ExternalPartyWalletAcsStoreRowData(_)), + mkFilter(validatorCodegen.ValidatorLivenessActivityRecord.COMPANION) { co => + co.payload.dso == dso && + co.payload.validator == externalParty + }(co => + ExternalPartyWalletAcsStoreRowData(co, rewardCouponRound = Some(co.payload.round.number)) + ), + mkFilter(UnclaimedActivityRecord.COMPANION) { co => + co.payload.dso == dso && + co.payload.beneficiary == externalParty + }(ExternalPartyWalletAcsStoreRowData(_)), + mkFilter(DevelopmentFundCoupon.COMPANION) { co => + co.payload.dso == dso && + co.payload.beneficiary == externalParty + }(ExternalPartyWalletAcsStoreRowData(_)), + // ValidatorRight is needed for collecting ValidatorRewardCoupons which + // may have been issued to the external party for traffic purchases, via + // MintingDelegation. The external party is both the user AND the + // "validator" in this case. + mkFilter(ValidatorRight.COMPANION) { co => + co.payload.dso == dso && + co.payload.user == externalParty && + co.payload.validator == externalParty + }(ExternalPartyWalletAcsStoreRowData(_)), ), ) } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/UserWalletStore.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/UserWalletStore.scala index 6f3532c45e..d4d4998027 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/UserWalletStore.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/UserWalletStore.scala @@ -19,6 +19,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.ans as ansCodegen import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.{ buytrafficrequest as trafficRequestCodegen, install as installCodegen, + mintingdelegation as mintingDelegationCodegen, payment as walletCodegen, subscriptions as subsCodegen, transferoffer as transferOffersCodegen, @@ -28,7 +29,14 @@ import org.lfdecentralizedtrust.splice.codegen.java.da.time.types.RelTime import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.* -import org.lfdecentralizedtrust.splice.store.{Limit, PageLimit, TransferInputStore, TxLogAppStore} +import org.lfdecentralizedtrust.splice.store.{ + Limit, + PageLimit, + ResultsPage, + SortOrder, + TransferInputStore, + TxLogAppStore, +} import org.lfdecentralizedtrust.splice.util.* import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore.* import org.lfdecentralizedtrust.splice.wallet.store.db.DbUserWalletStore @@ -182,6 +190,58 @@ trait UserWalletStore extends TxLogAppStore[TxLogEntry] with TransferInputStore requests <- multiDomainAcsStore.listContracts(subsCodegen.SubscriptionRequest.COMPANION, limit) } yield requests map (_.contract) + final def listMintingDelegationProposals( + after: Option[Long], + limit: PageLimit, + )(implicit tc: TraceContext): Future[ + ResultsPage[ + Contract[ + mintingDelegationCodegen.MintingDelegationProposal.ContractId, + mintingDelegationCodegen.MintingDelegationProposal, + ] + ] + ] = for { + page <- multiDomainAcsStore.listContractsPaginated( + mintingDelegationCodegen.MintingDelegationProposal.COMPANION, + after, + limit, + SortOrder.Ascending, + ) + } yield page.mapResultsInPage(_.contract) + + final def listMintingDelegations( + after: Option[Long], + limit: PageLimit, + )(implicit tc: TraceContext): Future[ + ResultsPage[ + Contract[ + mintingDelegationCodegen.MintingDelegation.ContractId, + mintingDelegationCodegen.MintingDelegation, + ] + ] + ] = for { + page <- multiDomainAcsStore.listContractsPaginated( + mintingDelegationCodegen.MintingDelegation.COMPANION, + after, + limit, + SortOrder.Ascending, + ) + } yield page.mapResultsInPage(_.contract) + + def listExpiredMintingDelegations: ListExpiredContracts[ + mintingDelegationCodegen.MintingDelegation.ContractId, + mintingDelegationCodegen.MintingDelegation, + ] = multiDomainAcsStore.listExpiredFromPayloadExpiry( + mintingDelegationCodegen.MintingDelegation.COMPANION + ) + + def listExpiredMintingDelegationProposals: ListExpiredContracts[ + mintingDelegationCodegen.MintingDelegationProposal.ContractId, + mintingDelegationCodegen.MintingDelegationProposal, + ] = multiDomainAcsStore.listExpiredFromPayloadExpiry( + mintingDelegationCodegen.MintingDelegationProposal.COMPANION + ) + def getAmuletBalanceWithHoldingFees(asOfRound: Long, deductHoldingFees: Boolean)(implicit tc: TraceContext ): Future[(BigDecimal, BigDecimal)] = for { @@ -708,6 +768,26 @@ object UserWalletStore { } { contract => UserWalletAcsStoreRowData(contract) }, + // Minting delegations for user as the delegate + mkFilter(mintingDelegationCodegen.MintingDelegationProposal.COMPANION)(co => + co.payload.delegation.dso == dso && + co.payload.delegation.delegate == endUser + )(contract => + UserWalletAcsStoreRowData( + contract, + contractExpiresAt = + Some(Timestamp.assertFromInstant(contract.payload.delegation.expiresAt)), + ) + ), + mkFilter(mintingDelegationCodegen.MintingDelegation.COMPANION)(co => + co.payload.dso == dso && + co.payload.delegate == endUser + )(contract => + UserWalletAcsStoreRowData( + contract, + contractExpiresAt = Some(Timestamp.assertFromInstant(contract.payload.expiresAt)), + ) + ), ), Map( mkFilterInterface(allocationrequestv1.AllocationRequest.INTERFACE)(co => diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbExternalPartyWalletStore.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbExternalPartyWalletStore.scala index 92f8e6fc8d..31845e7a2e 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbExternalPartyWalletStore.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbExternalPartyWalletStore.scala @@ -3,6 +3,9 @@ package org.lfdecentralizedtrust.splice.wallet.store.db +import org.lfdecentralizedtrust.splice.codegen.java.splice.validatorlicense as validatorCodegen +import org.lfdecentralizedtrust.splice.codegen.java.splice.round.IssuingMiningRound +import org.lfdecentralizedtrust.splice.codegen.java.splice.types.Round import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo import org.lfdecentralizedtrust.splice.store.db.StoreDescriptor @@ -11,18 +14,21 @@ import org.lfdecentralizedtrust.splice.store.db.{ AcsQueries, AcsTables, DbAppStore, + DbTransferInputQueries, } -import org.lfdecentralizedtrust.splice.store.LimitHelpers -import org.lfdecentralizedtrust.splice.util.TemplateJsonDecoder +import org.lfdecentralizedtrust.splice.store.{Limit, LimitHelpers} +import org.lfdecentralizedtrust.splice.util.{Contract, TemplateJsonDecoder} import org.lfdecentralizedtrust.splice.wallet.store.ExternalPartyWalletStore import com.digitalasset.canton.lifecycle.CloseContext import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.topology.ParticipantId import org.lfdecentralizedtrust.splice.config.IngestionConfig import scala.concurrent.* +import scala.jdk.OptionConverters.* class DbExternalPartyWalletStore( override val key: ExternalPartyWalletStore.Key, @@ -33,15 +39,15 @@ class DbExternalPartyWalletStore( participantId: ParticipantId, ingestionConfig: IngestionConfig, )(implicit - ec: ExecutionContext, - templateJsonDecoder: TemplateJsonDecoder, - closeContext: CloseContext, + override protected val ec: ExecutionContext, + override protected val templateJsonDecoder: TemplateJsonDecoder, + override protected val closeContext: CloseContext, ) extends DbAppStore( storage = storage, acsTableName = WalletTables.externalPartyAcsTableName, interfaceViewsTableNameOpt = None, acsStoreDescriptor = StoreDescriptor( - version = 2, + version = 3, name = "DbExternalPartyWalletStore", party = key.externalParty, participant = participantId, @@ -55,10 +61,18 @@ class DbExternalPartyWalletStore( ingestionConfig, ) with ExternalPartyWalletStore + with DbTransferInputQueries with AcsTables with AcsQueries with LimitHelpers { + import org.lfdecentralizedtrust.splice.store.db.AcsQueries.AcsStoreId + + override protected def acsStoreId: AcsStoreId = multiDomainAcsStore.acsStoreId + override protected def domainMigrationId: Long = domainMigrationInfo.currentMigrationId + override protected def acsTableName: String = WalletTables.externalPartyAcsTableName + override protected def dbStorage: DbStorage = storage + override def toString: String = show"DbExternalPartyWalletStore(externalParty=${key.externalParty})" @@ -67,4 +81,22 @@ class DbExternalPartyWalletStore( AcsInterfaceViewRowData.NoInterfacesIngested, ] = ExternalPartyWalletStore.contractFilter(key) + override def listSortedLivenessActivityRecords( + issuingRoundsMap: Map[Round, IssuingMiningRound], + limit: Limit = Limit.DefaultLimit, + )(implicit tc: TraceContext): Future[Seq[ + ( + Contract[ + validatorCodegen.ValidatorLivenessActivityRecord.ContractId, + validatorCodegen.ValidatorLivenessActivityRecord, + ], + BigDecimal, + ) + ]] = listSortedRewardCoupons( + validatorCodegen.ValidatorLivenessActivityRecord.COMPANION, + issuingRoundsMap, + _.optIssuancePerValidatorFaucetCoupon.toScala.map(BigDecimal(_)), + limit, + ) + } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbUserWalletStore.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbUserWalletStore.scala index bbfae880cc..dc75780e1b 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbUserWalletStore.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/store/db/DbUserWalletStore.scala @@ -3,7 +3,6 @@ package org.lfdecentralizedtrust.splice.wallet.store.db -import com.daml.ledger.javaapi.data.codegen.ContractId import com.daml.ledger.javaapi.data.codegen.json.JsonLfReader import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet as amuletCodegen import org.lfdecentralizedtrust.splice.codegen.java.splice.amuletrules.TransferPreapproval @@ -15,13 +14,14 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.subscriptions import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.transferpreapproval.TransferPreapprovalProposal import org.lfdecentralizedtrust.splice.environment.RetryProvider import org.lfdecentralizedtrust.splice.migration.DomainMigrationInfo -import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.{ContractCompanion, QueryResult} +import org.lfdecentralizedtrust.splice.store.MultiDomainAcsStore.QueryResult import org.lfdecentralizedtrust.splice.store.db.AcsQueries.{AcsStoreId, SelectFromAcsTableResult} import org.lfdecentralizedtrust.splice.store.db.StoreDescriptor import org.lfdecentralizedtrust.splice.store.db.{ AcsQueries, AcsTables, DbTxLogAppStore, + DbTransferInputQueries, TxLogQueries, } import org.lfdecentralizedtrust.splice.store.{Limit, LimitHelpers, PageLimit, TxLogStore} @@ -46,7 +46,6 @@ import com.digitalasset.canton.resource.DbStorage.Implicits.BuilderChain.toSQLAc import com.digitalasset.canton.topology.{ParticipantId, PartyId} import org.lfdecentralizedtrust.splice.config.IngestionConfig import org.lfdecentralizedtrust.splice.store.db.TxLogQueries.TxLogStoreId -import slick.jdbc.canton.SQLActionBuilder import scala.concurrent.* import scala.jdk.OptionConverters.* @@ -60,9 +59,9 @@ class DbUserWalletStore( participantId: ParticipantId, ingestionConfig: IngestionConfig, )(implicit - ec: ExecutionContext, - templateJsonDecoder: TemplateJsonDecoder, - closeContext: CloseContext, + override protected val ec: ExecutionContext, + override protected val templateJsonDecoder: TemplateJsonDecoder, + override protected val closeContext: CloseContext, ) extends DbTxLogAppStore[TxLogEntry]( storage = storage, acsTableName = WalletTables.acsTableName, @@ -71,7 +70,7 @@ class DbUserWalletStore( // Any change in the store descriptor will lead to previously deployed applications // forgetting all persisted data once they upgrade to the new version. acsStoreDescriptor = StoreDescriptor( - version = 3, + version = 4, name = "DbUserWalletStore", party = key.endUserParty, participant = participantId, @@ -98,6 +97,7 @@ class DbUserWalletStore( ingestionConfig, ) with UserWalletStore + with DbTransferInputQueries with AcsTables with AcsQueries with TxLogQueries[TxLogEntry] @@ -106,9 +106,11 @@ class DbUserWalletStore( import multiDomainAcsStore.waitUntilAcsIngested import org.lfdecentralizedtrust.splice.util.FutureUnlessShutdownUtil.futureUnlessShutdownToFuture - private def acsStoreId: AcsStoreId = multiDomainAcsStore.acsStoreId + override protected def acsStoreId: AcsStoreId = multiDomainAcsStore.acsStoreId private def txLogStoreId: TxLogStoreId = multiDomainAcsStore.txLogStoreId override def domainMigrationId: Long = domainMigrationInfo.currentMigrationId + override protected def acsTableName: String = WalletTables.acsTableName + override protected def dbStorage: DbStorage = storage override def toString: String = show"DbUserWalletStore(endUserParty=${key.endUserParty})" @@ -188,53 +190,6 @@ class DbUserWalletStore( ccValue = sql"rti.issuance * acs.reward_coupon_weight", ) - private def listSortedRewardCoupons[C, TCid <: ContractId[?], T]( - companion: C, - issuingRoundsMap: Map[Round, IssuingMiningRound], - roundToIssuance: IssuingMiningRound => Option[BigDecimal], - limit: Limit, - ccValue: SQLActionBuilder = sql"rti.issuance", - )(implicit - companionClass: ContractCompanion[C, TCid, T], - traceContext: TraceContext, - ): Future[Seq[(Contract[TCid, T], BigDecimal)]] = { - val packageQualifiedName = companionClass.packageQualifiedName(companion) - issuingRoundsMap - .flatMap { case (round, contract) => - roundToIssuance(contract).map(round.number.longValue() -> _) - } - .map { case (round, issuance) => - sql"($round, $issuance)" - } - .reduceOption { (acc, next) => - (acc ++ sql"," ++ next).toActionBuilder - } match { - case None => Future.successful(Seq.empty) // no rounds = no results - case Some(roundToIssuance) => - for { - result <- storage.query( - (sql""" - with round_to_issuance(round, issuance) as (values """ ++ roundToIssuance ++ sql""") - select - #${SelectFromAcsTableResult.sqlColumnsCommaSeparated()},""" ++ ccValue ++ sql""" - from #${WalletTables.acsTableName} acs join round_to_issuance rti on acs.reward_coupon_round = rti.round - where acs.store_id = $acsStoreId - and migration_id = $domainMigrationId - and acs.package_name = ${packageQualifiedName.packageName} - and acs.template_id_qualified_name = ${packageQualifiedName.qualifiedName} - order by (acs.reward_coupon_round, -""" ++ ccValue ++ sql""") - limit ${sqlLimit(limit)}""").toActionBuilder - .as[(SelectFromAcsTableResult, BigDecimal)], - s"listSorted:$packageQualifiedName", - ) - } yield applyLimit(s"listSorted:$packageQualifiedName", limit, result).map { - case (row, issuance) => - val contract = contractFromRow(companion)(row) - contract -> issuance - } - } - } - override def listTransactions( beginAfterEventIdO: Option[String], limit: PageLimit, diff --git a/daml/dars.lock b/daml/dars.lock index 4a4d755093..8ad66a1630 100644 --- a/daml/dars.lock +++ b/daml/dars.lock @@ -99,6 +99,7 @@ splice-wallet 0.1.12 b30bb727552cf6b624dbc9a5ff95f6c158e0a654e2e9c5c27bcfe3f5d0f splice-wallet 0.1.13 eb6e01efacc3397e23c6be8b9be7db4bf37672211974d69e24b48980e2f98b7e splice-wallet 0.1.14 690c1d47bac06db419db344d59a7a30c53fa3f5d961943fe1782cfc6c78794d8 splice-wallet 0.1.15 fd57252dda29e3ce90028114c91b521cb661df5a9d6e87c41a9e91518215fa5b +splice-wallet 0.1.16 17dca10fd8eb6a833be530fe9c6f9c2b7397a38c06e9c86d0679adc200b90e14 splice-wallet 0.1.2 c162e08a4ec0428bfa870b6d9040989e575c74199c3a80558c62e03196dd5146 splice-wallet 0.1.3 2c35bb4f5084ea66db59717d21750bfd64c43147ef5fd5166615092d592a6917 splice-wallet 0.1.4 141dad2d33b6410b8e1c35a0c4f8f76cb691e4d9a4410ce89f33f373855317e1 @@ -123,7 +124,7 @@ splice-wallet-payments 0.1.6 6124379528eeb6fa17ecdab15577c29abb33d0c0d34dc5f2680 splice-wallet-payments 0.1.7 4e3e0d9cdadf80f4bf8f3cd3660d5287c084c9a29f23c901aabce597d72fd467 splice-wallet-payments 0.1.8 e48ea337ee3335c8bb3206a2501ce947ac1a7bdb1825cee8f28bad64f5a7bc4b splice-wallet-payments 0.1.9 7f4e081ad96f2ccded0c053b0cf5ddddae1139dfc3bb89cefcf77ea70f2cecb7 -splice-wallet-test 0.1.18 f658717551440b5c05502aee61b834020c4490aef2e4edce118c45313d386f21 +splice-wallet-test 0.1.19 6184b47ef584d5dd4b36a4ea97fa71e09af0c51e4569db5cd66c636e568b6f81 splitwell 0.1.0 075c76de553ab88383a7c69de134afa82aacfdf8ea8fcfe8852c4b199c3b2669 splitwell 0.1.1 ccb1a0215053062202052e1a052f9214da3fdae5253a6d43e2e155ff4f57fe75 splitwell 0.1.10 d42676a366f7ca7a2409974dd3054aa4d83ab29baa3b2086ad021407b0a1a295 @@ -132,6 +133,7 @@ splitwell 0.1.12 cc047977ee8da70e858f203a14c3fd302c6aaed27be42383e61a026854d7611 splitwell 0.1.13 c2cf7b5fb3c615cdd2c8e14af42f1ca5fe4df8647cb656c7d02a72420152c3dd splitwell 0.1.14 bf2ec3fec9bcb58ed5e2ff63072a1e4994d0415ea7a0275942be282906a42021 splitwell 0.1.15 2f3d8a50f57e66af450c36556a09d04c1d9117b699720118b7bd302556805499 +splitwell 0.1.16 2c8567bc0e7cd15d29de8dcbc8d992aa7a42a3805e9831765d670b03c7c2474a splitwell 0.1.2 778edd2c228c6b68198d4d033885b2d0dae7daaee55d7df3edd9dfdf1f10fbd0 splitwell 0.1.3 7cde068cde689584f86a2499689d5cb165264d96496721e24ac6fb909f770a58 splitwell 0.1.4 85557b86cd4f330f093915db1ea26eac5092de6b5ddae0690146f6059c89419b @@ -140,4 +142,4 @@ splitwell 0.1.6 872da0dd7986fd768930f85d6a7310a94a0ef924e7fbb7bb7a4e149f2b5feb74 splitwell 0.1.7 841d1c9c86b5c8f3a39059459ecd8febedf7703e18f117300bb0ebf4423db096 splitwell 0.1.8 63b8153a08ceb4bf40d807acc5712372c3eac548c266be4d5e92470b4f655515 splitwell 0.1.9 b6267905698d2798b9ef171e27d49fb88e052ec0ec0e0675a3a1b275c7d037d4 -splitwell-test 0.1.18 022ee9f4acf3c29af6abcc21ec8d5e3acac2dadfec123adfc97932b1f13348a6 \ No newline at end of file +splitwell-test 0.1.19 c6ec7ab2af6ff076b37ba6e5be2d6b615acf7d289d3c00ddd87ff7da349c7192 \ No newline at end of file diff --git a/daml/dars/splice-wallet-0.1.16.dar b/daml/dars/splice-wallet-0.1.16.dar new file mode 100644 index 0000000000..cc8c68c614 Binary files /dev/null and b/daml/dars/splice-wallet-0.1.16.dar differ diff --git a/daml/dars/splitwell-0.1.16.dar b/daml/dars/splitwell-0.1.16.dar new file mode 100644 index 0000000000..c49fbf2112 Binary files /dev/null and b/daml/dars/splitwell-0.1.16.dar differ diff --git a/daml/splice-wallet-test/daml.yaml b/daml/splice-wallet-test/daml.yaml index 5e65c83e29..df6e88dd1c 100644 --- a/daml/splice-wallet-test/daml.yaml +++ b/daml/splice-wallet-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet-test source: daml -version: 0.1.18 +version: 0.1.19 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml b/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml new file mode 100644 index 0000000000..1e739ea8ab --- /dev/null +++ b/daml/splice-wallet-test/daml/Splice/Scripts/Wallet/TestMintingDelegation.daml @@ -0,0 +1,258 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Scripts.Wallet.TestMintingDelegation where + +import DA.Time +import Daml.Script + +import qualified DA.Map as Map +import DA.List.Total (head) +import Splice.Amulet +import Splice.AmuletRules +import Splice.Round +import Splice.Util +import Splice.Wallet.MintingDelegation +import Splice.ValidatorLicense + +import Splice.Scripts.Util + +testMintingDelegation : Script () +testMintingDelegation = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + + beneficiary <- allocateParty "beneficiary" + let delegate = aliceValidator.primaryParty + + -- Create a MintingDelegation + now <- getTime + proposalCid <- submit beneficiary $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary + delegate + expiresAt = now `addRelTime` days 30 + amuletMergeLimit = 10 + + MintingDelegationProposal_AcceptResult mintingDelegationCid <- submit delegate $ exerciseCmd proposalCid MintingDelegationProposal_Accept with + existingDelegationCid = None + + -- Create a ValidatorRight for beneficiary (needed for ValidatorRewardCoupon) + validatorRightCid <- submit beneficiary $ createCmd ValidatorRight with + dso = app.dso + user = beneficiary + validator = beneficiary + + (_, openRound) <- getLatestOpenRound app + + livenessActivityRecordCid <- submit app.dso $ createCmd ValidatorLivenessActivityRecord with + dso = app.dso + validator = beneficiary + round = openRound.round + + let appRewardCouponAmount = 2.0 + appRewardCouponCid <- submit app.dso $ createCmd AppRewardCoupon with + dso = app.dso + provider = delegate + featured = False + amount = appRewardCouponAmount + round = openRound.round + beneficiary = Some beneficiary + + let validatorRewardCouponAmount = 10.0 + validatorRewardCouponCid <- submit app.dso $ createCmd ValidatorRewardCoupon with + dso = app.dso + user = beneficiary + amount = validatorRewardCouponAmount + round = openRound.round + + let unclaimedRewardCouponAmount = 30.0 + unclaimedActivityRecordCid <- submit app.dso $ createCmd UnclaimedActivityRecord with + dso = app.dso + beneficiary = beneficiary + amount = unclaimedRewardCouponAmount + reason = "" + expiresAt = now `addRelTime` days 60 + + let developmentFundCouponAmount = 50.0 + developmentFundCouponCid <- submit app.dso $ createCmd DevelopmentFundCoupon with + dso = app.dso + beneficiary = beneficiary + fundManager = app.dso + amount = developmentFundCouponAmount + expiresAt = now `addRelTime` days 60 + reason = "Test development fund coupon" + + -- Wait for rounds to advance so coupons can be minted + runNextIssuance app + runNextIssuance app + runNextIssuance app + + balanceBefore <- getNormalizedBalance beneficiary + (openMiningRoundCid, _) <- getLatestOpenRound app + now2 <- getTime + issuingRounds <- query @IssuingMiningRound app.dso + let context = TransferContext with + openMiningRound = openMiningRoundCid + issuingMiningRounds = Map.fromList + [ (round.round, roundCid) | (roundCid, round) <- issuingRounds, round.opensAt <= now2 ] + validatorRights = Map.fromList [(beneficiary, validatorRightCid)] + featuredAppRight = None + (amuletRulesCid, _) <- getAmuletRules app + + submitMulti [delegate] [beneficiary, app.dso] $ exerciseCmd mintingDelegationCid MintingDelegation_Mint with + inputs = [ + InputValidatorLivenessActivityRecord livenessActivityRecordCid, + InputAppRewardCoupon appRewardCouponCid, + InputValidatorRewardCoupon validatorRewardCouponCid, + InputUnclaimedActivityRecord unclaimedActivityRecordCid, + InputDevelopmentFundCoupon developmentFundCouponCid + ] + context = PaymentTransferContext with + context + amuletRules = amuletRulesCid + + balanceAfter <- getNormalizedBalance beneficiary + let reward = balanceAfter - balanceBefore + + let Some (_, issuingRound) = head issuingRounds + + -- expected rewards based on issuance rates + let expectedValidatorFaucetAmount = getIssuingMiningRoundIssuancePerValidatorFaucetCoupon issuingRound + let expectedAppReward = appRewardCouponAmount * issuingRound.issuancePerUnfeaturedAppRewardCoupon + let expectedValidatorReward = validatorRewardCouponAmount * issuingRound.issuancePerValidatorRewardCoupon + let expectedTotal = expectedValidatorFaucetAmount + expectedAppReward + expectedValidatorReward + unclaimedRewardCouponAmount + developmentFundCouponAmount + + require ("Combined reward should match expected " <> show expectedTotal <> ", got: " <> show reward) + (reward >= expectedTotal - 1.0 && reward <= expectedTotal + 1.0) + + pure () + +testMintingDelegation_checksExpiry : Script () +testMintingDelegation_checksExpiry = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + + beneficiary <- allocateParty "beneficiary" + let delegate = aliceValidator.primaryParty + let delegationLifetime = days 30 + + now <- getTime + proposalCid <- submit beneficiary $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary + delegate + expiresAt = now `addRelTime` delegationLifetime + amuletMergeLimit = 10 + + MintingDelegationProposal_AcceptResult mintingDelegationCid <- submit delegate $ exerciseCmd proposalCid MintingDelegationProposal_Accept with + existingDelegationCid = None + + validatorLicenseCid <- submit app.dso $ createCmd ValidatorLicense with + validator = beneficiary + sponsor = app.dso + dso = app.dso + faucetState = None + metadata = None + lastActiveAt = None + + -- Record an activity before expiry to have a valid livenessActivityRecordCid + (openRoundCid, _) <- getLatestOpenRound app + submitMulti [beneficiary] [app.dso] $ exerciseCmd validatorLicenseCid (ValidatorLicense_RecordValidatorLivenessActivity openRoundCid) + + [(livenessActivityRecordCid, _)] <- query @ValidatorLivenessActivityRecord beneficiary + + -- Advance rounds so record can be minted + runNextIssuance app + runNextIssuance app + runNextIssuance app + + -- Advance time past expiry + passTime delegationLifetime + + -- Mint should fail after expiry + context <- getTransferContext app alice None + (amuletRulesCid, _) <- getAmuletRules app + + submitMultiMustFail [delegate] [beneficiary, app.dso] $ exerciseCmd mintingDelegationCid MintingDelegation_Mint with + inputs = [InputValidatorLivenessActivityRecord livenessActivityRecordCid] + context = PaymentTransferContext with + context + amuletRules = amuletRulesCid + + pure () + +-- Archival of existing delegation succeeds only if the delegation parties match +-- with the new delegation being proposed +testMintingDelegation_AcceptWithExistingDelegationArchival : Script () +testMintingDelegation_AcceptWithExistingDelegationArchival = do + DefaultAppWithUsers{..} <- setupDefaultAppWithUsers + + beneficiary1 <- allocateParty "beneficiary1" + let delegate1 = aliceValidator.primaryParty + + now <- getTime + + proposalCid1 <- submit beneficiary1 $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary = beneficiary1 + delegate = delegate1 + expiresAt = now `addRelTime` days 30 + amuletMergeLimit = 10 + + MintingDelegationProposal_AcceptResult mintingDelegationCid1 <- submit delegate1 $ exerciseCmd proposalCid1 MintingDelegationProposal_Accept with + existingDelegationCid = None + + proposalSameParties <- submit beneficiary1 $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary = beneficiary1 + delegate = delegate1 + expiresAt = now `addRelTime` days 60 + amuletMergeLimit = 20 + + -- Happy path, should archive mintingDelegationCid1 + MintingDelegationProposal_AcceptResult mintingDelegationCid2 <- submit delegate1 $ exerciseCmd proposalSameParties MintingDelegationProposal_Accept with + existingDelegationCid = Some mintingDelegationCid1 + + oldDelegation <- queryContractId beneficiary1 mintingDelegationCid1 + require "Old delegation should be archived" (oldDelegation == None) + + -- Test 1: Should fail for wrong beneficiary + proposalWrongBeneficiary <- submit bobValidator.primaryParty $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary = bobValidator.primaryParty -- mismatch + delegate = delegate1 + expiresAt = now `addRelTime` days 60 + amuletMergeLimit = 20 + + submitMustFail delegate1 $ exerciseCmd proposalWrongBeneficiary MintingDelegationProposal_Accept with + existingDelegationCid = Some mintingDelegationCid2 + + -- Test 2: Should fail for wrong DSO party + proposalWrongDso <- submit beneficiary1 $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = bobValidator.primaryParty -- mismatch + beneficiary = beneficiary1 + delegate = delegate1 + expiresAt = now `addRelTime` days 60 + amuletMergeLimit = 20 + + submitMustFail delegate1 $ exerciseCmd proposalWrongDso MintingDelegationProposal_Accept with + existingDelegationCid = Some mintingDelegationCid2 + + -- Test 3: Should fail for wrong delegate + proposalWrongDelegate <- submit beneficiary1 $ createCmd MintingDelegationProposal with + delegation = MintingDelegation with + dso = app.dso + beneficiary = beneficiary1 + delegate = bobValidator.primaryParty -- mismatch + expiresAt = now `addRelTime` days 60 + amuletMergeLimit = 20 + + submitMustFail bobValidator.primaryParty $ exerciseCmd proposalWrongDelegate MintingDelegationProposal_Accept with + existingDelegationCid = Some mintingDelegationCid2 + + pure () diff --git a/daml/splice-wallet/daml.yaml b/daml/splice-wallet/daml.yaml index cf1971b83f..a8de304c87 100644 --- a/daml/splice-wallet/daml.yaml +++ b/daml/splice-wallet/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splice-wallet source: daml -version: 0.1.15 +version: 0.1.16 dependencies: - daml-prim - daml-stdlib @@ -18,6 +18,7 @@ build-options: - --ghc-option=-Wunused-binds - --ghc-option=-Wunused-matches - -Wno-deprecated-exceptions + - -Wno-ledger-time-is-alpha - --target=2.1 codegen: java: diff --git a/daml/splice-wallet/daml/Splice/Wallet/MintingDelegation.daml b/daml/splice-wallet/daml/Splice/Wallet/MintingDelegation.daml new file mode 100644 index 0000000000..e5970564a4 --- /dev/null +++ b/daml/splice-wallet/daml/Splice/Wallet/MintingDelegation.daml @@ -0,0 +1,113 @@ +-- Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +-- SPDX-License-Identifier: Apache-2.0 + +module Splice.Wallet.MintingDelegation where + +import DA.Assert +import Splice.AmuletRules +import Splice.Types (ForOwner(..)) +import Splice.Util + +-- | Proposal by the user to setup a minting delegation. +template MintingDelegationProposal + with + delegation : MintingDelegation + -- ^ The delegation to be created if accepted. + where + signatory delegation.beneficiary + observer delegation.delegate + + choice MintingDelegationProposal_Accept : MintingDelegationProposal_AcceptResult + with + existingDelegationCid : Optional (ContractId MintingDelegation) + -- ^ Optional existing delegation to archive while creating the new one. + -- Useful for changing the parameters of the delegation. + controller delegation.delegate + do + case existingDelegationCid of + Some cid -> do + existingDelegation <- fetchAndArchive (ForOwner with dso = delegation.dso; owner = delegation.beneficiary) cid + existingDelegation.delegate === delegation.delegate + None -> pure () + MintingDelegationProposal_AcceptResult <$> create delegation + + choice MintingDelegationProposal_Reject : MintingDelegationProposal_RejectResult + controller delegation.delegate + do pure MintingDelegationProposal_RejectResult {} + + choice MintingDelegationProposal_Withdraw : MintingDelegationProposal_WithdrawResult + controller delegation.beneficiary + do pure MintingDelegationProposal_WithdrawResult {} + +-- | The right allowing the delegate to mint rewards on behalf of the beneficiary. +template MintingDelegation + with + beneficiary : Party + -- ^ The party on whose behalf minting is performed. + delegate : Party + -- ^ The party authorized to perform minting operations. + dso : Party + -- ^ Expected DSO party. + expiresAt : Time + -- ^ The time after which this delegation is no longer valid. + amuletMergeLimit : Int + -- ^ The number of amulet contracts to keep after auto-merging; i.e., + -- auto-merge contracts once there are strictly more than this number of contracts. + -- This is a suggestion to the delegate and is not enforced in daml + where + signatory beneficiary, delegate + + nonconsuming choice MintingDelegation_Mint : MintingDelegation_MintResult + with + inputs : [TransferInput] + -- ^ The inputs to the transfer, like the validator liveness activity records owned by the beneficiary. + context : PaymentTransferContext + -- ^ The transfer context including amulet rules. + controller delegate + do assertWithinDeadline "expiresAt" expiresAt + transferResult <- exercise context.amuletRules AmuletRules_Transfer with + transfer = Transfer with + sender = beneficiary + provider = delegate + inputs + outputs = [] + beneficiaries = None + context = context.context with featuredAppRight = None + expectedDso = Some dso + pure MintingDelegation_MintResult with transferResult + + choice MintingDelegation_Reject : MintingDelegation_RejectResult + controller delegate + do pure MintingDelegation_RejectResult {} + + choice MintingDelegation_Withdraw : MintingDelegation_WithdrawResult + controller beneficiary + do pure MintingDelegation_WithdrawResult {} + +data MintingDelegationProposal_AcceptResult = MintingDelegationProposal_AcceptResult + with + mintingDelegationCid : ContractId MintingDelegation + deriving (Show, Eq) + +data MintingDelegationProposal_RejectResult = MintingDelegationProposal_RejectResult {} + deriving (Show, Eq) + +data MintingDelegationProposal_WithdrawResult = MintingDelegationProposal_WithdrawResult {} + deriving (Show, Eq) + +data MintingDelegation_RejectResult = MintingDelegation_RejectResult {} + deriving (Show, Eq) + +data MintingDelegation_WithdrawResult = MintingDelegation_WithdrawResult {} + deriving (Show, Eq) + +data MintingDelegation_MintResult = MintingDelegation_MintResult + with + transferResult : TransferResult + deriving (Show, Eq) + +instance HasCheckedFetch MintingDelegation ForOwner where + contractGroupId MintingDelegation{beneficiary, dso} = ForOwner with owner = beneficiary; dso + +instance HasCheckedFetch MintingDelegationProposal ForOwner where + contractGroupId MintingDelegationProposal{delegation = MintingDelegation{beneficiary, dso}} = ForOwner with owner = beneficiary; dso diff --git a/daml/splitwell-test/daml.yaml b/daml/splitwell-test/daml.yaml index ce9caec535..4c1753ed5c 100644 --- a/daml/splitwell-test/daml.yaml +++ b/daml/splitwell-test/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splitwell-test source: daml -version: 0.1.18 +version: 0.1.19 dependencies: - daml-prim - daml-stdlib diff --git a/daml/splitwell/daml.yaml b/daml/splitwell/daml.yaml index 762e112666..b80e2e49f5 100644 --- a/daml/splitwell/daml.yaml +++ b/daml/splitwell/daml.yaml @@ -1,7 +1,7 @@ sdk-version: 3.3.0-snapshot.20250502.13767.0.v2fc6c7e2 name: splitwell source: daml -version: 0.1.15 +version: 0.1.16 dependencies: - daml-prim - daml-stdlib diff --git a/test-full-class-names-sim-time.log b/test-full-class-names-sim-time.log index 00c374c563..89b5a2ef67 100644 --- a/test-full-class-names-sim-time.log +++ b/test-full-class-names-sim-time.log @@ -18,6 +18,7 @@ org.lfdecentralizedtrust.splice.integration.tests.TokenStandardMetadataTimeBased org.lfdecentralizedtrust.splice.integration.tests.ValidatorLicenseMetadataTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.WalletAmuletPriceTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.WalletAppRewardsTimeBasedIntegrationTest +org.lfdecentralizedtrust.splice.integration.tests.WalletMintingDelegationTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.WalletRewardsTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.WalletTimeBasedIntegrationTest org.lfdecentralizedtrust.splice.integration.tests.WalletTxLogTimeBasedIntegrationTest