Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,7 @@ abstract class WalletAppReference(
amount: BigDecimal,
expiresAt: CantonTimestamp,
reason: String,
mintAfter: Option[CantonTimestamp] = None,
): AllocateDevelopmentFundCouponResponse =
consoleEnvironment.run {
httpCommand(
Expand All @@ -717,6 +718,7 @@ abstract class WalletAppReference(
amount,
expiresAt,
reason,
mintAfter,
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,101 @@ class DevelopmentFundCouponIntegrationTest
}
}

"Delaying the claiming of a development fund coupon until its mintAfter" in { implicit env =>
onboardWalletUser(aliceValidatorWalletClient, aliceValidatorBackend)
val sv1UserId = sv1WalletClient.config.ledgerApiUser
val bobParty = onboardWalletUser(bobWalletClient, bobValidatorBackend)
val beneficiary = bobParty
val initialUnclaimedDevelopmentFundCouponAmount = BigDecimal(SpliceUtil.damlDecimal(1000))
val developmentFundCouponAmount = BigDecimal(SpliceUtil.damlDecimal(40.0))
val expiresAt = CantonTimestamp.now().plus(Duration.ofDays(1))
val reason = "Bob has contributed to the Daml repo"
val mintingDelay = Duration.ofSeconds(30)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing aspects related to timing we usually use a TimeBased integration test, which allowS changing the clock using advanceTime without busy waiting.

However, all the logic wrt minting delays is already tested at the Daml level. So what we need to test here is that a coupon with a minting delay gets eventually minted without raising unexpected errors.

I'd suggest we set a delay of 10s, which is long enough so it would result in a retry if the minting would be attempted, but short enough so its CI cost is not egregious. I'd then add 10s extra wait time also to the eventually that checks whether the coupon gets minted.

For bonus points, I'd change the minting code once to not respect the mint after, and check what kind of errors are raised in the logs (I expect retries); and then check again with the proper filtering that these are gone.


val bobUserName = bobWalletClient.config.ledgerApiUser
val bobMergeAmuletsTrigger =
bobValidatorBackend
.userWalletAutomation(bobUserName)
.futureValue
.trigger[CollectRewardsAndMergeAmuletsTrigger]

archiveExistingUnclaimedDevelopmentFundCoupons()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks fishy. We usually do test isolation by relying on fresh parties being allocated for new test instances. So if you scope your queries wrt coupons being created to the test-instance-specific party, then you have proper isolation.

actAndCheck(
"Mint one unclaimed development fund coupon", {
createUnclaimedDevelopmentFundCoupon(
sv1ValidatorBackend.participantClientWithAdminToken,
sv1UserId,
initialUnclaimedDevelopmentFundCouponAmount,
)
},
)(
"The unclaimed development fund coupon is created",
_ => {
getUnclaimedDevelopmentFundCouponTotal(
sv1ValidatorBackend
) shouldBe initialUnclaimedDevelopmentFundCouponAmount
},
)

val bobBalanceBefore = bobWalletClient.balance().unlockedQty
val (mintAfter, _) = setTriggersWithin(
triggersToPauseAtStart = Seq(bobMergeAmuletsTrigger)
) {
actAndCheck(
"Allocate one development fund coupon that is not mintable yet", {
val mintAfter = CantonTimestamp.now().plus(mintingDelay)
aliceValidatorWalletClient.allocateDevelopmentFundCoupon(
beneficiary,
developmentFundCouponAmount,
expiresAt,
reason,
Some(mintAfter),
)
mintAfter
},
)(
"The coupon is created and carries the requested mintAfter",
allocatedMintAfter => {
val coupons = bobWalletClient.listActiveDevelopmentFundCoupons()
coupons should have size 1 withClue "bob coupons"
coupons.head.payload.mintAfter shouldBe java.util.Optional.of(
allocatedMintAfter.toInstant
)
},
)
}

clue("The coupon is left alone while its mintAfter is in the future") {
always(durationOfSuccess = 10.seconds) {
bobWalletClient
.listActiveDevelopmentFundCoupons() should have size 1 withClue "bob coupons before mintAfter"
bobWalletClient.balance().unlockedQty shouldBe bobBalanceBefore
}
CantonTimestamp
.now()
.isBefore(mintAfter) shouldBe true withClue "still before mintAfter"
}
Comment on lines +500 to +509

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to test this, as that's already tested at the Daml level.

Suggested change
clue("The coupon is left alone while its mintAfter is in the future") {
always(durationOfSuccess = 10.seconds) {
bobWalletClient
.listActiveDevelopmentFundCoupons() should have size 1 withClue "bob coupons before mintAfter"
bobWalletClient.balance().unlockedQty shouldBe bobBalanceBefore
}
CantonTimestamp
.now()
.isBefore(mintAfter) shouldBe true withClue "still before mintAfter"
}


clue("The coupon is collected once its mintAfter has passed") {
eventually(60.seconds) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very expensive! Let's avoid it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the above suggestion, this would be 30s, as the eventually has a 20s default and we add the 10s extra from the delay to get some extra protection from flakes.

bobWalletClient
.listActiveDevelopmentFundCoupons() shouldBe empty withClue "bob coupons after mintAfter"
bobWalletClient.balance().unlockedQty shouldBe
(bobBalanceBefore + developmentFundCouponAmount)
}
}

clue("The collected coupon is listed in listDevelopmentFundCouponHistory as claimed") {
eventually() {
assertListDevelopmentFundCouponHistoryStatuses(
bobWalletClient,
beneficiary,
Seq(httpDef.ArchivedDevelopmentFundCoupon.Status.Claimed -> None),
)
}
}
}

"Expiring a development fund coupon" in { implicit env =>
val sv1UserId = sv1WalletClient.config.ledgerApiUser
onboardWalletUser(aliceValidatorWalletClient, aliceValidatorBackend)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ class DevelopmentFundFrontendTimeBasedIntegrationTest

val futureExpiresAtFormatted =
formatDateTimeForUI(latestTime.plus(Duration.ofDays(365 * 30)))
val mintAfterInstant = latestTime.plus(Duration.ofDays(10))
val mintAfterFormatted = formatDateTimeForUI(mintAfterInstant)

// ===================================================================
// Section: Create coupons for user_1, change DFM, and verify transition
Expand Down Expand Up @@ -250,20 +252,48 @@ class DevelopmentFundFrontendTimeBasedIntegrationTest
"development-fund-allocation-expires-at",
futureExpiresAtFormatted,
)
waitForQuery(id("development-fund-allocation-mint-after"))
webDriver
.findElement(
org.openqa.selenium.By.id("development-fund-allocation-mint-after")
)
.getAttribute("value") should not be empty
setDateTimeWithoutScroll(
"development-fund-allocation-mint-after",
mintAfterFormatted,
)
eventuallyClickOn(id("development-fund-allocation-reason"))
textArea(id("development-fund-allocation-reason")).underlying.sendKeys(
"Coupon 3 - stays active"
)
eventuallyClickOn(id("development-fund-allocation-submit-button"))
},
)(
"Coupon 3 is allocated",
"Coupon 3 is allocated carrying the mint delay entered in the form",
_ => {
eventually() {
aliceWalletClient.listActiveDevelopmentFundCoupons() should have size 1
val coupons = aliceWalletClient.listActiveDevelopmentFundCoupons()
coupons should have size 1
val couponMintAfter = coupons.head.payload.mintAfter.toScala.value
couponMintAfter.isAfter(
mintAfterInstant.minus(Duration.ofDays(1))
) shouldBe true withClue "mintAfter lower bound"
couponMintAfter.isBefore(
mintAfterInstant.plus(Duration.ofDays(1))
) shouldBe true withClue "mintAfter upper bound"
}
},
)

clue("Check: the active coupon row renders its Mint After") {
eventually() {
val mintAfterCells =
findAll(cssSelector("#active-coupons-table tbody tr td:nth-child(5)")).toSeq
mintAfterCells should have size 1
mintAfterCells.head.text should fullyMatch regex
"""[A-Z][a-z]{2} \d{1,2}, \d{4} \d{2}:\d{2} [AP]M"""
}
}
}
}

Expand Down Expand Up @@ -374,7 +404,7 @@ class DevelopmentFundFrontendTimeBasedIntegrationTest
clue("Check: user_2's Active List is empty") {
eventually() {
val emptyStateCell = find(
cssSelector("#active-coupons-table tbody tr td[colspan='6']")
cssSelector("#active-coupons-table tbody tr td[colspan='7']")
)
emptyStateCell.isDefined shouldBe true
emptyStateCell.value.text should include("No development fund allocations found")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,119 @@ class WalletMintingDelegationTimeBasedIntegrationTest
}
}

"not collect a development fund coupon before its mintAfter" in { implicit env =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the same reason as explained above, I don't think we need an additional test, which increases both CI and maintenance cost. We just want to check that the coupons are ignored when their time is not due, and collected afterwards.

I'd suggest to restructure the existing test to do so as follows:

  1. create two dev fund coupons: one with mintAfter = None and another one with mintAfter = Some 24h.
  2. adjust https://github.com/bitdynamics-ab/splice/blob/860446c52b0247b6c3720bc7b7949300aad85fad/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/WalletMintingDelegationTimeBasedIntegrationTest.scala#L591-L612 to expect to not collect the dev fund coupon that has a mintAfter
  3. add an advanceTime(25h) afterwards and then check that the coupon with mintAfter is also collected.

val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend)
aliceWalletClient.tap(100.0)
aliceValidatorWalletClient.tap(100.0)

val beneficiaryParty =
onboardExternalParty(aliceValidatorBackend, Some("delayed_coupon_beneficiary"))
createAndAcceptExternalPartySetupProposal(aliceValidatorBackend, beneficiaryParty)

val delegationExpiresAt = env.environment.clock.now.plus(Duration.ofDays(30)).toInstant
val (_, proposalContractId) = actAndCheck(
"Create minting delegation proposal",
createMintingDelegationProposal(beneficiaryParty, aliceParty, delegationExpiresAt),
)(
"Proposal is visible",
_ => {
val proposals = aliceWalletClient.listMintingDelegationProposals()
proposals.proposals should have size 1 withClue "proposals"
proposals.proposals.head.contract.contractId
},
)

actAndCheck(
"Alice accepts the proposal",
aliceWalletClient.acceptMintingDelegationProposal(proposalContractId),
)(
"Delegation is created",
_ => {
val delegations = aliceWalletClient.listMintingDelegations()
delegations.delegations should have size 1 withClue "delegations"
},
)

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

val balanceBefore = getBalance()
val developmentFundAmount = BigDecimal(300.0)
// Short enough that advancing past it does not disturb round automation.
val mintDelay = Duration.ofMinutes(10)

val mintAfter = env.environment.clock.now.plus(mintDelay).toInstant
val couponExpiresAt = env.environment.clock.now.plus(Duration.ofDays(30)).toInstant

val validatorRewardTrigger = collectRewardsAndMergeAmuletsTrigger(
aliceValidatorBackend,
aliceValidatorWalletClient.config.ledgerApiUser,
)

setTriggersWithin(triggersToPauseAtStart = Seq(validatorRewardTrigger)) {
val externalPartyMintingDelegationTrigger = mintingDelegationCollectRewardsTrigger(
aliceValidatorBackend,
beneficiaryParty.party,
)

setTriggersWithin(triggersToPauseAtStart = Seq(externalPartyMintingDelegationTrigger)) {
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,
developmentFundAmount.bigDecimal,
couponExpiresAt,
"delayed development fund coupon",
java.util.Optional.of(mintAfter),
).create,
)
}

clue("Coupon is left alone while mintAfter is in the future") {
(1 to 3).foreach(_ => advanceTime(Duration.ofMinutes(1)))
externalPartyWallet.store
.listDevelopmentFundCoupons()
.futureValue should have size 1 withClue "DevelopmentFundCoupon before mintAfter"
getBalance() shouldBe balanceBefore
}

actAndCheck(
"Advance past mintAfter",
advanceTime(mintDelay.plus(Duration.ofHours(1))),
)(
"Coupon is collected",
_ => {
advanceTime(Duration.ofSeconds(1))
externalPartyWallet.store
.listDevelopmentFundCoupons()
.futureValue shouldBe empty withClue "DevelopmentFundCoupon after mintAfter"
getBalance() shouldBe balanceBefore + developmentFundAmount
},
)
}
}

"assign and mint unassigned V2 coupons when sharing is configured" in { implicit env =>
val aliceParty = onboardWalletUser(aliceWalletClient, aliceValidatorBackend)
aliceWalletClient.tap(100.0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,50 @@ describe('buildAmuletRulesConfigFromChanges', () => {
]);
});

test('should round-trip the development fund blacklist and minting delay', () => {
const changes: ConfigChange[] = [
{
fieldName: 'developmentFundManagerBlacklist',
label: 'Development Fund Manager Blacklist',
currentValue: 'alice::122',
newValue: 'alice::122, bob::122',
},
{
fieldName: 'minDevelopmentFundMintingDelay',
label: 'Min Development Fund Minting Delay',
currentValue: '',
newValue: '604800000000',
},
];

const result = buildAmuletRulesConfigFromChanges(changes);

expect(result.developmentFundManagerBlacklist).toEqual(['alice::122', 'bob::122']);
expect(result.minDevelopmentFundMintingDelay).toEqual({ microseconds: '604800000000' });
});

test('should map an emptied development fund blacklist to an empty list and the delay to null', () => {
const changes: ConfigChange[] = [
{
fieldName: 'developmentFundManagerBlacklist',
label: 'Development Fund Manager Blacklist',
currentValue: 'alice::122',
newValue: ' , ',
},
{
fieldName: 'minDevelopmentFundMintingDelay',
label: 'Min Development Fund Minting Delay',
currentValue: '604800000000',
newValue: '',
},
];

const result = buildAmuletRulesConfigFromChanges(changes);

expect(result.developmentFundManagerBlacklist).toEqual([]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about setting it to null if it is emptied?

expect(result.minDevelopmentFundMintingDelay).toBeNull();
});

test('should handle issuance curve future values', () => {
const changes: ConfigChange[] = [
{
Expand Down
13 changes: 13 additions & 0 deletions apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export function buildAmuletConfigChanges(
currentValue: before?.optDevelopmentFundManager || '',
newValue: after?.optDevelopmentFundManager || '',
},
{
fieldName: 'developmentFundManagerBlacklist',
label: 'Blacklisted development fund managers (comma-separated party ids)',
currentValue: before?.developmentFundManagerBlacklist?.join(', ') || '',
newValue: after?.developmentFundManagerBlacklist?.join(', ') || '',
},
{
fieldName: 'minDevelopmentFundMintingDelay',
label:
'Minimum delay between allocating and minting a development fund coupon in microseconds',
currentValue: before?.minDevelopmentFundMintingDelay?.microseconds || '',
newValue: after?.minDevelopmentFundMintingDelay?.microseconds || '',
},
{
fieldName: 'transferConfigCreateFee',
label: 'Fee per created output contract in a transfer',
Expand Down
Loading
Loading