Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9cbb28d
Daml: Add MintingDelegation
dfordivam Jan 20, 2026
d98d2c9
Add MintingDelegation automation, APIs, and HTTP handler
dfordivam Jan 7, 2026
45cd83e
DevelopmentFundCoupon minting in MintingDelegation
dfordivam Jan 20, 2026
eff24a6
Move listSortedRewardCoupons to common code DbTransferInputQueries
dfordivam Jan 15, 2026
f919249
[ci]
dfordivam Jan 7, 2026
b23d843
Begin the delegations pane with a table of delegations
Dec 22, 2025
935f495
Add the table showing the proposals
Jan 15, 2026
47705ee
Wire the various buttons to the corresponding actions
Dec 22, 2025
636742e
Shorten the benificiaryId
Dec 22, 2025
8748069
Show beneficiary onboarding status
Jan 13, 2026
f6b08ce
Add confirmation dialogs for the Accept/Withdraw/Reject buttons
Jan 15, 2026
05aa3d3
Sort the delegations and proposal tables
Dec 24, 2025
e8fc449
Show popup when approving a Proposal matching a Delegation
Jan 15, 2026
37e1892
Verify delegations/proposals using WalletFrontendIntegrationTest
Jan 15, 2026
2e83449
Update formatting
Jan 20, 2026
6df3da6
Confirm that proposal and delegations from non-onboard users are seen
Jan 20, 2026
0e0c5ab
Use a non-validator user in the UI integration test
Jan 20, 2026
14041f1
More reformatting
Jan 20, 2026
9236d62
[ci]
dfordivam Jan 20, 2026
e45e4ae
Update table and column headings
Jan 23, 2026
77d18b9
Extract shortenPartyId into a util module
Jan 23, 2026
41b233c
Remove MintingDelegationProposalWithOnboardedStatus
Jan 23, 2026
cd62b37
Replace Onboarded column with a warning icon
Jan 23, 2026
8bd9723
Use 'hosted' rather than 'onboarded' in non-generated code
Jan 23, 2026
8990113
Rename beneficiaryOnboard to beneficiaryHosted in api and backend
Jan 23, 2026
8bcd148
Show popup when approving a Proposal matching a Delegation
Jan 23, 2026
9bcf04f
[ci]
dfordivam Jan 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.{
AllocateAmuletResponse,
GetBuyTrafficRequestStatusResponse,
GetTransferOfferStatusResponse,
ListMintingDelegationProposalsResponse,
ListMintingDelegationsResponse,
TransferInstructionResultResponse,
}
import org.lfdecentralizedtrust.splice.util.{Contract, ContractWithState}
Expand Down Expand Up @@ -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.,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,16 @@ trait FrontendTestCommon extends TestCommon with WebBrowser with CustomMatchers
clickOn(query)
}

protected def clickByCssSelector(selector: String)(implicit
webDriver: WebDriver
): Unit = {
val query = cssSelector(selector)
waitForCondition(query) {
ExpectedConditions.elementToBeClickable(_)
}
eventuallyClickOn(query)
}

protected def eventuallyFind(query: Query)(implicit driver: WebDriver) = {
clue(s"Waiting for $query to be found") {
waitForCondition(query) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.lfdecentralizedtrust.splice.integration.tests

import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.mintingdelegation as mintingDelegationCodegen
import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition
import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment
import org.lfdecentralizedtrust.splice.util.{
Expand All @@ -8,12 +9,18 @@ import org.lfdecentralizedtrust.splice.util.{
WalletFrontendTestUtil,
WalletTestUtil,
}
import com.digitalasset.canton.topology.PartyId
import org.openqa.selenium.WebDriver

import java.time.Duration
import scala.jdk.CollectionConverters.*

class WalletFrontendIntegrationTest
extends FrontendIntegrationTestWithSharedEnvironment("alice")
with WalletTestUtil
with WalletFrontendTestUtil
with FrontendLoginUtil {
with FrontendLoginUtil
with ExternallySignedPartyTestUtil {

val amuletPrice = 2
override def walletAmuletPrice = SpliceUtil.damlDecimal(amuletPrice.toDouble)
Expand Down Expand Up @@ -200,6 +207,245 @@ class WalletFrontendIntegrationTest

}

"with delegations and their proposals" should {

"allow them to be accepted, rejected or withdrawn as appropriate" in { implicit env =>
def checkRowCounts(proposalCount: Long, activeCount: Long)(implicit
webDriver: WebDriver
): Unit = {
val proposalRows = findAll(className("proposal-row")).toSeq
proposalRows should have size proposalCount
val delegationRows = findAll(className("delegation-row")).toSeq
delegationRows should have size activeCount
}

// 1. Setup
val aliceDamlUser = aliceWalletClient.config.ledgerApiUser
onboardWalletUser(aliceWalletClient, aliceValidatorBackend)
val aliceParty =
PartyId.tryFromProtoPrimitive(aliceWalletClient.userStatus().party)

// Tap to fund the validator wallet for external party setup
// (external party operations go through aliceValidatorBackend)
aliceValidatorWalletClient.tap(100.0)

// Onboard three external parties as beneficiaries
val beneficiary1Onboarding =
onboardExternalParty(aliceValidatorBackend, Some("beneficiary1"))
createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiary1Onboarding)

val beneficiary2Onboarding =
onboardExternalParty(aliceValidatorBackend, Some("beneficiary2"))
createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiary2Onboarding)

val beneficiary3Onboarding =
onboardExternalParty(aliceValidatorBackend, Some("beneficiary3"))
createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiary3Onboarding)

// 2. Verify empty initial state via API
clue("Check that no minting delegation proposals exist initially") {
aliceWalletClient.listMintingDelegationProposals().proposals shouldBe empty
}
clue("Check that no minting delegations exist initially") {
aliceWalletClient.listMintingDelegations().delegations shouldBe empty
}

// 3. Create three proposals, one from each beneficiary
val envNow = env.environment.clock.now
val expiresAt = envNow.plus(Duration.ofDays(30)).toInstant
val expiresDayAfter = envNow.plus(Duration.ofDays(31)).toInstant
actAndCheck(
"Each beneficiary creates a minting delegation proposal", {
createMintingDelegationProposal(beneficiary1Onboarding, aliceParty, expiresAt)
createMintingDelegationProposal(beneficiary2Onboarding, aliceParty, expiresAt)
createMintingDelegationProposal(beneficiary3Onboarding, aliceParty, expiresDayAfter)
},
)(
"and they are successfully created",
_ => {
aliceWalletClient
.listMintingDelegationProposals()
.proposals should have size 3
},
)

// 4. Test via Selenium UI (using Alice's wallet frontend)
withFrontEnd("alice") { implicit webDriver =>
actAndCheck(
"Alice browses to the wallet", {
browseToAliceWallet(aliceDamlUser)
},
)(
"Alice sees the Delegations tab",
_ => {
waitForQuery(id("navlink-delegations"))
},
)

actAndCheck(
"Alice clicks on Delegations tab", {
eventuallyClickOn(id("navlink-delegations"))
},
)(
"Alice sees the Proposed table with 3 proposals and empty Delegations table",
_ => {
find(id("proposals-label")).valueOrFail("Proposed heading not found!")
val proposalRows = findAll(className("proposal-row")).toSeq
proposalRows should have size 3

proposalRows.foreach { row =>
row
.findChildElement(className("proposal-accept"))
.valueOrFail("Accept button not found in proposal row!")
row
.findChildElement(className("proposal-reject"))
.valueOrFail("Reject button not found in proposal row!")
}

find(id("delegations-label")).valueOrFail("Delegations heading not found!")
find(id("no-delegations-message")).valueOrFail("No delegations message not found!")
},
)

// 5. Accept first proposal via UI
actAndCheck(
"Alice clicks Accept on the first proposal and confirms", {
clickByCssSelector(".proposal-row .proposal-accept")
eventuallyClickOn(id("accept-proposal-confirmation-dialog-accept-button"))
},
)(
"2 proposals remain, 1 delegation created",
_ => {
eventually() {
checkRowCounts(2, 1)
}
},
)

// 6. Accept second proposal via UI
actAndCheck(
"Alice clicks Accept on the second proposal and confirms", {
clickByCssSelector(".proposal-row .proposal-accept")
eventuallyClickOn(id("accept-proposal-confirmation-dialog-accept-button"))
},
)(
"1 proposal remains, 2 delegations exist",
_ => {
eventually() {
checkRowCounts(1, 2)
}
},
)

// 7. Withdraw one delegation via UI
actAndCheck(
"Alice clicks Withdraw on the first delegation and confirms", {
clickByCssSelector(".delegation-row .delegation-withdraw")
eventuallyClickOn(id("withdraw-delegation-confirmation-dialog-accept-button"))
},
)(
"1 proposal remains, 1 delegation remains",
_ => {
eventually() {
checkRowCounts(1, 1)
}
},
)

// 8. Reject the final proposal via UI
actAndCheck(
"Alice clicks Reject on the final proposal and confirms", {
clickByCssSelector(".proposal-row .proposal-reject")
eventuallyClickOn(id("reject-proposal-confirmation-dialog-accept-button"))
},
)(
"No proposals remain, 1 delegation remains",
_ => {
eventually() {
find(id("no-proposals-message")).valueOrFail("No proposals message not found!")
checkRowCounts(0, 1)
}
},
)

// 9. Create two proposals, from beneficiaries that have not completedly onboarding
val beneficiary4Incomplete =
onboardExternalParty(aliceValidatorBackend, Some("beneficiary4"))
val beneficiary5Incomplete =
onboardExternalParty(aliceValidatorBackend, Some("beneficiary5"))
actAndCheck(
"Each beneficiary creates a minting delegation proposal", {
createMintingDelegationProposal(beneficiary4Incomplete, aliceParty, expiresDayAfter)
createMintingDelegationProposal(beneficiary5Incomplete, aliceParty, expiresDayAfter)
webDriver.navigate().refresh()
},
)(
"2 new proposals appear, 1 delegation remains",
_ => {
eventually() {
checkRowCounts(2, 1)
}
},
)

actAndCheck(
"Accept a non-onboarded proposal via api, refresh the UI", {
val proposals = aliceWalletClient.listMintingDelegationProposals()
val cid = proposals.proposals.head.contract.contractId
aliceWalletClient.acceptMintingDelegationProposal(cid)
webDriver.navigate().refresh()
},
)(
"1 proposal remain, 2 delegations are visible",
_ => {
eventually() {
checkRowCounts(1, 2)
}
},
)

// 10. Add another proposal, refresh the UI and confirm that it appears
actAndCheck(
"Beneficiary 2 creates new minting proposal and UI refreshes", {
createLimitedMintingDelegationProposal(
beneficiary2Onboarding,
aliceParty,
expiresAt,
18,
)
webDriver.navigate().refresh()
},
)(
"1 new proposal appears making 2, 2 delegations remain",
_ => {
eventually() {
aliceWalletClient
.listMintingDelegationProposals()
.proposals should have size 2
checkRowCounts(2, 2)
}
},
)

// 11. Accept proposal that causes automatic withdraw of existing delegation for beneficary 2
actAndCheck(
"Alice clicks Accept on new proposal and confirms", {
clickByCssSelector(".proposal-row .proposal-accept")
eventuallyClickOn(id("accept-proposal-confirmation-dialog-accept-button"))
},
)(
"1 proposal and 2 delegations remain",
_ => {
eventually() {
checkRowCounts(1, 2)
}
},
)
}
}

}

"show logged in ANS name" in { implicit env =>
// Create directory entry for alice
val aliceDamlUser = aliceWalletClient.config.ledgerApiUser
Expand Down Expand Up @@ -239,6 +485,36 @@ class WalletFrontendIntegrationTest
)
}
}
}

private def createMintingDelegationProposal(
beneficiaryOnboarding: OnboardingResult,
delegate: PartyId,
expiresAt: java.time.Instant,
)(implicit env: SpliceTestConsoleEnvironment): Unit =
createLimitedMintingDelegationProposal(beneficiaryOnboarding, delegate, expiresAt, 10)

private def createLimitedMintingDelegationProposal(
beneficiaryOnboarding: OnboardingResult,
delegate: PartyId,
expiresAt: java.time.Instant,
mergeLimit: Int,
)(implicit env: SpliceTestConsoleEnvironment): Unit = {
val beneficiary = beneficiaryOnboarding.party
val proposal = new mintingDelegationCodegen.MintingDelegationProposal(
new mintingDelegationCodegen.MintingDelegation(
beneficiary.toProtoPrimitive,
delegate.toProtoPrimitive,
dsoParty.toProtoPrimitive,
expiresAt,
mergeLimit,
)
)
// Use externally signed submission for the external party
aliceValidatorBackend.participantClientWithAdminToken.ledger_api_extensions.commands
.submitJavaExternalOrLocal(
actingParty = beneficiaryOnboarding.richPartyId,
commands = proposal.create.commands.asScala.toSeq,
)
}
}
Loading
Loading