From 1da9b10cad421948c37f3bec3a7a4e153fa3de0b Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Fri, 21 Aug 2026 20:10:25 +0000 Subject: [PATCH 1/4] Add developmentFunManagerBlacklist and minDevelopmentFundMintingDelay in frontend Signed-off-by: Zhe Li --- .../buildAmuletRulesConfigFromChanges.test.ts | 44 +++++++++++++++++++ .../src/utils/buildAmuletConfigChanges.ts | 14 ++++++ .../buildAmuletRulesConfigFromChanges.ts | 14 +++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts index 1035962057..56f0058176 100644 --- a/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts +++ b/apps/sv/frontend/src/__tests__/utils/buildAmuletRulesConfigFromChanges.test.ts @@ -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([]); + expect(result.minDevelopmentFundMintingDelay).toBeNull(); + }); + test('should handle issuance curve future values', () => { const changes: ConfigChange[] = [ { diff --git a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts index c7b3994d66..2e052a853f 100644 --- a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts @@ -46,6 +46,20 @@ 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', diff --git a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts index aead26894f..5560c2ee48 100644 --- a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts @@ -93,6 +93,12 @@ export function buildAmuletRulesConfigFromChanges( true ); const transferConfigTokenStandardMaxTTL = getValue('transferConfigTokenStandardMaxTTL', true); + const developmentFundManagerBlacklist = + getValue('developmentFundManagerBlacklist', true) + ?.split(',') + .map(party => party.trim()) + .filter(party => party !== '') ?? []; + const minDevelopmentFundMintingDelay = getValue('minDevelopmentFundMintingDelay', true); const rewardConfigMintingVersion = getValue('rewardConfigMintingVersion', true); const amuletConfig: AmuletConfig<'USD'> = { tickDuration: { microseconds: getValue('tickDuration', false) }, @@ -104,8 +110,12 @@ export function buildAmuletRulesConfigFromChanges( ? null : { microseconds: externalPartyConfigStateTickDuration }, transferPreapprovalBaseDuration: null, - developmentFundManagerBlacklist: null, - minDevelopmentFundMintingDelay: null, + // `null` marks a DSO that has not upgraded yet, so an emptied list stays `Some []` + developmentFundManagerBlacklist, + minDevelopmentFundMintingDelay: + minDevelopmentFundMintingDelay === null + ? null + : { microseconds: minDevelopmentFundMintingDelay }, transferConfig: { createFee: { fee: getValue('transferConfigCreateFee', false) }, holdingFee: { rate: getValue('transferConfigHoldingFeeRate', false) }, From 4e48f1e16ea06dbb97f4a8b21fa82f5fb2b2c970 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Mon, 24 Aug 2026 17:46:56 +0000 Subject: [PATCH 2/4] [ci] Add UI and backend support for minDevelopmentFundMintingDelay and mintAfter with tests Signed-off-by: Zhe Li --- ...ngDelegationTimeBasedIntegrationTest.scala | 113 +++++++++++++ .../buildAmuletRulesConfigFromChanges.ts | 3 +- .../src/__tests__/developmentFund.test.tsx | 60 +++++++ .../useDevelopmentFundAllocationForm.test.tsx | 153 ++++++++++++++++++ .../components/DevelopmentFundAllocation.tsx | 25 +++ .../components/DevelopmentFundCouponList.tsx | 12 +- .../src/contexts/WalletServiceContext.tsx | 10 +- .../hooks/useDevelopmentFundAllocationForm.ts | 58 ++++++- apps/wallet/frontend/src/models/models.ts | 1 + .../src/main/openapi/wallet-internal.yaml | 5 + .../client/commands/HttpWalletAppClient.scala | 2 + .../wallet/admin/http/HttpWalletHandler.scala | 8 +- ...ntingDelegationCollectRewardsTrigger.scala | 8 +- .../wallet/treasury/TreasuryService.scala | 8 +- .../util/DevelopmentFundCouponUtil.scala | 19 +++ 15 files changed, 472 insertions(+), 13 deletions(-) create mode 100644 apps/wallet/frontend/src/__tests__/useDevelopmentFundAllocationForm.test.tsx create mode 100644 apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala 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 index 473e6d298b..27864c7093 100644 --- 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 @@ -659,6 +659,119 @@ class WalletMintingDelegationTimeBasedIntegrationTest } } + "not collect a development fund coupon before its mintAfter" in { implicit env => + 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) diff --git a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts index 5560c2ee48..31384d83f1 100644 --- a/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletRulesConfigFromChanges.ts @@ -110,7 +110,8 @@ export function buildAmuletRulesConfigFromChanges( ? null : { microseconds: externalPartyConfigStateTickDuration }, transferPreapprovalBaseDuration: null, - // `null` marks a DSO that has not upgraded yet, so an emptied list stays `Some []` + // The frontend will never send null for developmentFundManagerBlacklist, an empty list is sent as `Some []` + // so that we can distinguish DSO that have not upgraded yet (they will have None for the field) developmentFundManagerBlacklist, minDevelopmentFundMintingDelay: minDevelopmentFundMintingDelay === null diff --git a/apps/wallet/frontend/src/__tests__/developmentFund.test.tsx b/apps/wallet/frontend/src/__tests__/developmentFund.test.tsx index f81025a0b8..480dfa003a 100644 --- a/apps/wallet/frontend/src/__tests__/developmentFund.test.tsx +++ b/apps/wallet/frontend/src/__tests__/developmentFund.test.tsx @@ -50,6 +50,11 @@ const buildAllocationFormMock = ( setAmount: vi.fn(), expiresAt: dayjs().add(2, 'day'), setExpiresAt: vi.fn(), + mintAfter: dayjs().add(1, 'hour'), + setMintAfter: vi.fn(), + minMintAfter: dayjs(), + isMintAfterValid: true, + mintAfterError: undefined, reason: 'Valid allocation', setReason: vi.fn(), amountNum: new BigNumber(1), @@ -558,6 +563,7 @@ describe('Development Fund page', () => { test('triggers allocation request on allocate click', async () => { const mutate = vi.fn(); const expiresAt = dayjs().add(2, 'day'); + const mintAfter = dayjs().add(1, 'hour'); const hookSpy = vi .spyOn(developmentFundAllocationFormHook, 'useDevelopmentFundAllocationForm') @@ -570,6 +576,11 @@ describe('Development Fund page', () => { setAmount: vi.fn(), expiresAt, setExpiresAt: vi.fn(), + mintAfter, + setMintAfter: vi.fn(), + minMintAfter: dayjs(), + isMintAfterValid: true, + mintAfterError: undefined, reason: 'Valid allocation', setReason: vi.fn(), amountNum: new BigNumber(1), @@ -599,8 +610,57 @@ describe('Development Fund page', () => { amount: new BigNumber(1), expiresAt: expiresAt.toDate(), reason: 'Valid allocation', + mintAfter: mintAfter.toDate(), }); hookSpy.mockRestore(); }); + + test('sends the chosen mintAfter with the allocation', async () => { + const mutate = vi.fn(); + const expiresAt = dayjs().add(2, 'day'); + const mintAfter = dayjs().add(1, 'day'); + + const hookSpy = vi + .spyOn(developmentFundAllocationFormHook, 'useDevelopmentFundAllocationForm') + .mockReturnValue( + buildAllocationFormMock({ + expiresAt, + mintAfter, + allocateMutation: { + mutate, + isPending: false, + } as unknown as ReturnType< + typeof developmentFundAllocationFormHook.useDevelopmentFundAllocationForm + >['allocateMutation'], + }) + ); + + const { user } = await loginAndOpenDevelopmentFund(); + await user.click(await screen.findByRole('button', { name: 'Allocate' })); + + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ mintAfter: mintAfter.toDate() })); + + hookSpy.mockRestore(); + }); + + test('disables Allocate button when mintAfter is invalid', async () => { + const hookSpy = vi + .spyOn(developmentFundAllocationFormHook, 'useDevelopmentFundAllocationForm') + .mockReturnValue( + buildAllocationFormMock({ + mintAfter: null, + isMintAfterValid: false, + mintAfterError: 'Mint after is required', + isValid: false, + }) + ); + + await loginAndOpenDevelopmentFund(); + + expect(await screen.findByRole('button', { name: 'Allocate' })).toBeDisabled(); + expect(screen.getByText('Mint after is required')).toBeDefined(); + + hookSpy.mockRestore(); + }); }); diff --git a/apps/wallet/frontend/src/__tests__/useDevelopmentFundAllocationForm.test.tsx b/apps/wallet/frontend/src/__tests__/useDevelopmentFundAllocationForm.test.tsx new file mode 100644 index 0000000000..e3e9439f60 --- /dev/null +++ b/apps/wallet/frontend/src/__tests__/useDevelopmentFundAllocationForm.test.tsx @@ -0,0 +1,153 @@ +// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import BigNumber from 'bignumber.js'; +import dayjs, { Dayjs } from 'dayjs'; +import { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import useGetAmuletRules from '../hooks/scan-proxy/useGetAmuletRules'; +import { + useDevelopmentFundAllocationForm, + UseDevelopmentFundAllocationFormResult, +} from '../hooks/useDevelopmentFundAllocationForm'; +import { alicePartyId } from './mocks/constants'; + +vi.mock('../hooks/scan-proxy/useGetAmuletRules', () => ({ default: vi.fn() })); + +vi.mock('../hooks/useIsDevelopmentFundManager', () => ({ + useIsDevelopmentFundManager: () => ({ isFundManager: true, isLoading: false }), +})); + +vi.mock('../hooks/useUnclaimedDevelopmentFundTotal', () => ({ + useUnclaimedDevelopmentFundTotal: () => ({ + data: new BigNumber(100), + isLoading: false, + isError: false, + error: null, + invalidate: vi.fn(), + }), +})); + +vi.mock('../contexts/WalletServiceContext', () => ({ + useWalletClient: () => ({ allocateDevelopmentFundCoupon: vi.fn() }), +})); + +const openedAt = new Date('2026-01-15T12:00:00.000Z'); +const sevenDaysInMicros = String(7 * 24 * 60 * 60 * 1_000_000); + +const mockMintingDelay = (minDevelopmentFundMintingDelay: { microseconds: string } | null) => { + vi.mocked(useGetAmuletRules).mockReturnValue({ + data: { + contract: { + payload: { configSchedule: { initialValue: { minDevelopmentFundMintingDelay } } }, + }, + }, + } as unknown as ReturnType); +}; + +const renderForm = () => { + const queryClient = new QueryClient(); + return renderHook(() => useDevelopmentFundAllocationForm(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); +}; + +const fillRequiredFields = ( + result: { current: UseDevelopmentFundAllocationFormResult }, + expiresAt: Dayjs +) => + act(() => { + result.current.setBeneficiary(alicePartyId); + result.current.setAmount('1'); + result.current.setExpiresAt(expiresAt); + result.current.setReason('Valid allocation'); + }); + +describe('useDevelopmentFundAllocationForm', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(openedAt); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // Happy + test('treats an absent minting delay as a zero delay and still prefills mintAfter', () => { + mockMintingDelay(null); + + const { result } = renderForm(); + fillRequiredFields(result, dayjs(openedAt).add(2, 'day')); + + expect(result.current.minMintAfter.toISOString()).toBe('2026-01-15T12:00:00.000Z'); + expect(result.current.mintAfter?.toISOString()).toBe('2026-01-15T13:00:00.000Z'); + expect(result.current.mintAfterError).toBeUndefined(); + expect(result.current.isValid).toBe(true); + }); + + // Happy + test('offsets minMintAfter by the configured minting delay', () => { + mockMintingDelay({ microseconds: sevenDaysInMicros }); + + const { result } = renderForm(); + fillRequiredFields(result, dayjs(openedAt).add(30, 'day')); + + expect(result.current.minMintAfter.toISOString()).toBe('2026-01-22T12:00:00.000Z'); + expect(result.current.mintAfter?.toISOString()).toBe('2026-01-22T13:00:00.000Z'); + expect(result.current.mintAfterError).toBeUndefined(); + expect(result.current.isValid).toBe(true); + }); + + // Unhappy + test('requires a mintAfter after the fund manager clears the field', () => { + mockMintingDelay(null); + + const { result } = renderForm(); + fillRequiredFields(result, dayjs(openedAt).add(2, 'day')); + act(() => result.current.setMintAfter(null)); + + expect(result.current.mintAfterError).toBe('Mint after is required'); + expect(result.current.isMintAfterValid).toBe(false); + expect(result.current.isValid).toBe(false); + }); + + // Unhappy + test('rejects a mintAfter that fell into the past while the form stayed open', () => { + mockMintingDelay(null); + + const { result } = renderForm(); + vi.setSystemTime(dayjs(openedAt).add(2, 'hour').toDate()); + act(() => result.current.setMintAfter(dayjs(openedAt).add(1, 'hour'))); + + expect(result.current.mintAfterError).toBe('Mint after must be in the future'); + expect(result.current.isMintAfterValid).toBe(false); + }); + + // Unhappy + test('rejects a mintAfter earlier than the configured minting delay allows', () => { + mockMintingDelay({ microseconds: sevenDaysInMicros }); + + const { result } = renderForm(); + act(() => result.current.setMintAfter(dayjs(openedAt).add(1, 'day'))); + + expect(result.current.mintAfterError).toMatch(/^Mint after must be at or after /); + expect(result.current.isMintAfterValid).toBe(false); + }); + + // Unhappy + test('rejects a mintAfter that is not before the expiry', () => { + mockMintingDelay(null); + + const { result } = renderForm(); + fillRequiredFields(result, dayjs(openedAt).add(2, 'day')); + act(() => result.current.setMintAfter(dayjs(openedAt).add(3, 'day'))); + + expect(result.current.mintAfterError).toBe('Mint after must be before the expiry'); + expect(result.current.isValid).toBe(false); + }); +}); diff --git a/apps/wallet/frontend/src/components/DevelopmentFundAllocation.tsx b/apps/wallet/frontend/src/components/DevelopmentFundAllocation.tsx index a86f9f3b57..838ab530ce 100644 --- a/apps/wallet/frontend/src/components/DevelopmentFundAllocation.tsx +++ b/apps/wallet/frontend/src/components/DevelopmentFundAllocation.tsx @@ -30,6 +30,10 @@ const DevelopmentFundAllocation: React.FC = () => { setAmount, expiresAt, setExpiresAt, + mintAfter, + setMintAfter, + minMintAfter, + mintAfterError, reason, setReason, amountNum, @@ -124,6 +128,26 @@ const DevelopmentFundAllocation: React.FC = () => { }} /> + + + Mint After + setMintAfter(newValue)} + minDateTime={minMintAfter} + disabled={disabled} + enableAccessibleFieldDOMStructure={false} + slotProps={{ + textField: { + id: 'development-fund-allocation-mint-after', + fullWidth: true, + error: !!mintAfterError, + helperText: mintAfterError, + }, + }} + /> + Reason @@ -167,6 +191,7 @@ const DevelopmentFundAllocation: React.FC = () => { amount: amountNum, expiresAt: expiresAt.toDate(), reason, + mintAfter: mintAfter?.toDate(), }) } > diff --git a/apps/wallet/frontend/src/components/DevelopmentFundCouponList.tsx b/apps/wallet/frontend/src/components/DevelopmentFundCouponList.tsx index 2195534590..5e6287c85f 100644 --- a/apps/wallet/frontend/src/components/DevelopmentFundCouponList.tsx +++ b/apps/wallet/frontend/src/components/DevelopmentFundCouponList.tsx @@ -125,6 +125,7 @@ const ActiveCouponsTable: React.FC = () => { Beneficiary Amount Expires At + Mint After Allocation Reason Actions @@ -132,7 +133,7 @@ const ActiveCouponsTable: React.FC = () => { {coupons.length === 0 ? ( - + No development fund allocations found @@ -153,6 +154,15 @@ const ActiveCouponsTable: React.FC = () => { + + {coupon.mintAfter ? ( + + ) : ( + + - + + )} + {coupon.reason} {coupon.fundManager === primaryParty ? ( diff --git a/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx b/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx index 6dde43d82a..270a0a7d39 100644 --- a/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx +++ b/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx @@ -167,7 +167,8 @@ export interface WalletClient { beneficiary: string, amount: BigNumber, expiresAt: Date, - reason: string + reason: string, + mintAfter?: Date ) => Promise; listActiveDevelopmentFundCoupons: () => Promise; listCouponHistoryEvents: ( @@ -509,13 +510,15 @@ export const WalletClientProvider: React.FC beneficiary: string, amount: BigNumber, expiresAt: Date, - reason: string + reason: string, + mintAfter?: Date ): Promise => { const request = { beneficiary: beneficiary, amount: amount.isInteger() ? amount.toFixed(1) : amount.toString(), expiresAt: expiresAt.getTime() * 1000, reason: reason, + mintAfter: mintAfter ? mintAfter.getTime() * 1000 : undefined, }; await walletClient.allocateDevelopmentFundCoupon(request); }, @@ -530,6 +533,9 @@ export const WalletClientProvider: React.FC beneficiary: contract.payload.beneficiary, amount: new BigNumber(contract.payload.amount), expiresAt: new Date(contract.payload.expiresAt), + mintAfter: contract.payload.mintAfter + ? new Date(contract.payload.mintAfter) + : undefined, reason: contract.payload.reason, }; }); diff --git a/apps/wallet/frontend/src/hooks/useDevelopmentFundAllocationForm.ts b/apps/wallet/frontend/src/hooks/useDevelopmentFundAllocationForm.ts index d3dfe401c2..382b5e646e 100644 --- a/apps/wallet/frontend/src/hooks/useDevelopmentFundAllocationForm.ts +++ b/apps/wallet/frontend/src/hooks/useDevelopmentFundAllocationForm.ts @@ -4,6 +4,7 @@ import { useState, useMemo } from 'react'; import { useMutation, useQueryClient, UseMutationResult } from '@tanstack/react-query'; import { extractApiErrorMessage } from '@canton-network/splice-common-frontend'; import { useWalletClient } from '../contexts/WalletServiceContext'; +import useGetAmuletRules from './scan-proxy/useGetAmuletRules'; import { useIsDevelopmentFundManager } from './useIsDevelopmentFundManager'; import { useUnclaimedDevelopmentFundTotal } from './useUnclaimedDevelopmentFundTotal'; import { invalidateAllDevelopmentFundQueries } from '../utils/invalidateDevelopmentFundQueries'; @@ -19,6 +20,11 @@ export interface UseDevelopmentFundAllocationFormResult { setAmount: (value: string) => void; expiresAt: Dayjs | null; setExpiresAt: (value: Dayjs | null) => void; + mintAfter: Dayjs | null; + setMintAfter: (value: Dayjs | null) => void; + minMintAfter: Dayjs; + isMintAfterValid: boolean; + mintAfterError: string | undefined; reason: string; setReason: (value: string) => void; amountNum: BigNumber | null; @@ -39,12 +45,16 @@ interface AllocationPayload { amount: BigNumber; expiresAt: Date; reason: string; + mintAfter?: Date; } +const SUBMISSION_HEADROOM_HOURS = 1; + export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocationFormResult => { const { allocateDevelopmentFundCoupon } = useWalletClient(); const { isFundManager } = useIsDevelopmentFundManager(); const { data: unclaimedTotal } = useUnclaimedDevelopmentFundTotal(); + const { data: amuletRulesData } = useGetAmuletRules(); const queryClient = useQueryClient(); const [formKey, setFormKey] = useState(0); @@ -52,8 +62,24 @@ export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocation const [beneficiary, setBeneficiary] = useState(''); const [amount, setAmount] = useState(''); const [expiresAt, setExpiresAt] = useState(null); + const [mintAfterOverride, setMintAfterOverride] = useState(undefined); + const [defaultBaseTime, setDefaultBaseTime] = useState(() => dayjs()); const [reason, setReason] = useState(''); + const minMintingDelayMicros = + amuletRulesData?.contract.payload.configSchedule.initialValue.minDevelopmentFundMintingDelay + ?.microseconds; + const { minMintAfter, defaultMintAfter } = useMemo(() => { + const earliest = defaultBaseTime.add(Number(minMintingDelayMicros ?? 0) / 1000, 'millisecond'); + return { + minMintAfter: earliest, + defaultMintAfter: earliest.add(SUBMISSION_HEADROOM_HOURS, 'hour'), + }; + }, [minMintingDelayMicros, defaultBaseTime]); + + const mintAfter = mintAfterOverride !== undefined ? mintAfterOverride : defaultMintAfter; + const setMintAfter = (value: Dayjs | null) => setMintAfterOverride(value); + const amountNum = useMemo(() => (amount ? new BigNumber(amount) : null), [amount]); const isAmountValid = amountNum !== null && amountNum.isFinite() && amountNum.gt(0); const amountExceedsAvailable = isAmountValid && amountNum.gt(unclaimedTotal); @@ -67,11 +93,33 @@ export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocation : undefined : undefined; const isReasonValid = reason.trim().length > 0; + + const mintAfterError = (() => { + if (mintAfter == null) { + return 'Mint after is required'; + } + if (!mintAfter.isValid()) { + return 'Invalid date'; + } + if (mintAfter.isBefore(minMintAfter)) { + return `Mint after must be at or after ${minMintAfter.format('MMM D, YYYY hh:mm A')}`; + } + if (!mintAfter.isAfter(dayjs())) { + return 'Mint after must be in the future'; + } + if (expiresAt != null && expiresAt.isValid() && !mintAfter.isBefore(expiresAt)) { + return 'Mint after must be before the expiry'; + } + return undefined; + })(); + const isMintAfterValid = mintAfterError === undefined; + const isValid = Boolean(beneficiary) && isAmountValid && !amountExceedsAvailable && isExpiryValid && + isMintAfterValid && isReasonValid; const resetForm = () => { @@ -79,6 +127,8 @@ export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocation setBeneficiary(''); setAmount(''); setExpiresAt(null); + setMintAfterOverride(undefined); + setDefaultBaseTime(dayjs()); setReason(''); setFormKey(prev => prev + 1); }; @@ -89,7 +139,8 @@ export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocation data.beneficiary, data.amount, data.expiresAt, - data.reason + data.reason, + data.mintAfter ); }, onSuccess: () => { @@ -111,6 +162,11 @@ export const useDevelopmentFundAllocationForm = (): UseDevelopmentFundAllocation setAmount, expiresAt, setExpiresAt, + mintAfter, + setMintAfter, + minMintAfter, + isMintAfterValid, + mintAfterError, reason, setReason, amountNum, diff --git a/apps/wallet/frontend/src/models/models.ts b/apps/wallet/frontend/src/models/models.ts index b7af05928e..25c6391bf7 100644 --- a/apps/wallet/frontend/src/models/models.ts +++ b/apps/wallet/frontend/src/models/models.ts @@ -138,6 +138,7 @@ export interface DevelopmentFundCoupon { beneficiary: string; amount: BigNumber; expiresAt: Date; + mintAfter?: Date; reason: string; withdrawalReason?: string; } diff --git a/apps/wallet/src/main/openapi/wallet-internal.yaml b/apps/wallet/src/main/openapi/wallet-internal.yaml index cad6344105..65cc5aa0cf 100644 --- a/apps/wallet/src/main/openapi/wallet-internal.yaml +++ b/apps/wallet/src/main/openapi/wallet-internal.yaml @@ -2220,6 +2220,11 @@ components: format: int64 reason: type: string + mintAfter: + type: integer + format: int64 + description: | + Earliest time at which the beneficiary may mint the coupon, in epoch microseconds. AllocateDevelopmentFundCouponResponse: type: object 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 2536451b66..e9841e389a 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 @@ -1616,6 +1616,7 @@ object HttpWalletAppClient { amount: BigDecimal, expiresAt: CantonTimestamp, reason: String, + mintAfter: Option[CantonTimestamp] = None, ) extends InternalBaseCommand[ http.AllocateDevelopmentFundCouponResponse, definitions.AllocateDevelopmentFundCouponResponse, @@ -1632,6 +1633,7 @@ object HttpWalletAppClient { Codec.encode(amount), Codec.encode(expiresAt), reason, + mintAfter.map(Codec.encode(_)), ), headers = headers, ) 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 ec602d0194..dd2d4b92f8 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 @@ -1773,6 +1773,7 @@ class HttpWalletHandler( beneficiary = Codec.tryDecode(Codec.Party)(body.beneficiary) amount = Codec.tryDecode(Codec.BigDecimal)(body.amount) expiresAt = Codec.tryDecode(Codec.Timestamp)(body.expiresAt) + mintAfter = body.mintAfter.map(Codec.tryDecode(Codec.Timestamp)(_).toInstant) optDevelopmentFundManager = amuletRulesCt.contract.payload.configSchedule.initialValue.optDevelopmentFundManager .map(Codec.tryDecode(Codec.Party)(_)) @@ -1814,8 +1815,7 @@ class HttpWalletHandler( expiresAt.toInstant, body.reason, developmentFundManager.toProtoPrimitive, - // TODO(#6722): expose `mintAfter` in the wallet API - java.util.Optional.empty(), + mintAfter.toJava, ) ) result <- userWallet.connection @@ -1830,7 +1830,9 @@ class HttpWalletHandler( .CommandId( "org.lfdecentralizedtrust.splice.wallet.allocateDevelopmentFundCoupon", Seq(store.key.validatorParty, store.key.endUserParty), - Seq(s"${body.beneficiary}:${body.amount}:${body.expiresAt}:${body.reason}"), + Seq( + s"${body.beneficiary}:${body.amount}:${body.expiresAt}:${body.reason}:${body.mintAfter}" + ), ), deduplicationConfig = dedupDuration, ) 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 index 925ef6ad08..b3fee8f9e5 100644 --- 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 @@ -53,6 +53,7 @@ import org.lfdecentralizedtrust.splice.util.{ } import org.lfdecentralizedtrust.splice.wallet.config.RewardSharingConfig import org.lfdecentralizedtrust.splice.wallet.store.ExternalPartyWalletStore +import org.lfdecentralizedtrust.splice.wallet.util.DevelopmentFundCouponUtil import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.util.ShowUtil.* import org.lfdecentralizedtrust.splice.util.PrettyInstances.* @@ -361,13 +362,16 @@ class MintingDelegationCollectRewardsTrigger( limit = HardLimit.tryCreate(rewardSharingConfig.batchSize), ) unclaimedActivityRecords <- store.listUnclaimedActivityRecords() - developmentFundCoupons <- store.listDevelopmentFundCoupons() + allDevelopmentFundCoupons <- store.listDevelopmentFundCoupons() + mintableDevelopmentFundCoupons = allDevelopmentFundCoupons.filter( + DevelopmentFundCouponUtil.isMintable(_, context.clock.now.toInstant) + ) } yield CouponsData( livenessActivityRecordsWithQuantity.map(_._1), validatorRewardCoupons, appRewardCouponsWithQuantity.map(_._1), unclaimedActivityRecords, - developmentFundCoupons, + mintableDevelopmentFundCoupons, rewardCouponsV2.map(_.contract), ) } diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala index ba8dffb809..17e064a4d8 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/treasury/TreasuryService.scala @@ -63,6 +63,7 @@ import org.lfdecentralizedtrust.splice.util.{ import org.lfdecentralizedtrust.splice.wallet.UserWalletManager import org.lfdecentralizedtrust.splice.wallet.config.TreasuryConfig import org.lfdecentralizedtrust.splice.wallet.store.UserWalletStore +import org.lfdecentralizedtrust.splice.wallet.util.DevelopmentFundCouponUtil import org.lfdecentralizedtrust.splice.wallet.treasury.TreasuryService.* import com.digitalasset.base.error.utils.ErrorDetails import com.digitalasset.base.error.utils.ErrorDetails.ErrorInfoDetail @@ -1270,9 +1271,10 @@ class TreasuryService( tc: TraceContext ): Future[(BigDecimal, Seq[(BigDecimal, InputDevelopmentFundCoupon)])] = for { - developmentFundCouponsInputs <- userStore.listDevelopmentFundCoupons( - PageLimit.tryCreate(maxNumInputs) - ) + allDevelopmentFundCoupons <- userStore.listDevelopmentFundCoupons() + developmentFundCouponsInputs = allDevelopmentFundCoupons + .filter(DevelopmentFundCouponUtil.isMintable(_, clock.now.toInstant)) + .take(maxNumInputs) developmentFundCouponsQuantity = developmentFundCouponsInputs .map(coupon => scala.math.BigDecimal(coupon.payload.amount)) .sum diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala new file mode 100644 index 0000000000..aa7ccc78bb --- /dev/null +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala @@ -0,0 +1,19 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package org.lfdecentralizedtrust.splice.wallet.util + +import org.lfdecentralizedtrust.splice.codegen.java.splice.amulet.DevelopmentFundCoupon +import org.lfdecentralizedtrust.splice.util.Contract + +import java.time.Instant +import scala.jdk.OptionConverters.* + +object DevelopmentFundCouponUtil { + + def isMintable( + coupon: Contract[DevelopmentFundCoupon.ContractId, DevelopmentFundCoupon], + now: Instant, + ): Boolean = + coupon.payload.mintAfter.toScala.forall(mintAfter => !mintAfter.isAfter(now)) +} From a518c8837e9791fb12fa1c958dd0032aecdf553b Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Mon, 24 Aug 2026 22:15:24 +0000 Subject: [PATCH 3/4] [ci] Update integration test for mintAfter and minDevelopmentFundMintingDelay Signed-off-by: Zhe Li --- .../splice/console/WalletAppReference.scala | 2 + ...DevelopmentFundCouponIntegrationTest.scala | 95 +++++++++++++++++++ ...FundFrontendTimeBasedIntegrationTest.scala | 36 ++++++- 3 files changed, 130 insertions(+), 3 deletions(-) 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 2e646a636f..6fea5e9833 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 @@ -709,6 +709,7 @@ abstract class WalletAppReference( amount: BigDecimal, expiresAt: CantonTimestamp, reason: String, + mintAfter: Option[CantonTimestamp] = None, ): AllocateDevelopmentFundCouponResponse = consoleEnvironment.run { httpCommand( @@ -717,6 +718,7 @@ abstract class WalletAppReference( amount, expiresAt, reason, + mintAfter, ) ) } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala index a8a1bae694..7b3b4d8011 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundCouponIntegrationTest.scala @@ -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) + + val bobUserName = bobWalletClient.config.ledgerApiUser + val bobMergeAmuletsTrigger = + bobValidatorBackend + .userWalletAutomation(bobUserName) + .futureValue + .trigger[CollectRewardsAndMergeAmuletsTrigger] + + archiveExistingUnclaimedDevelopmentFundCoupons() + 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" + } + + clue("The coupon is collected once its mintAfter has passed") { + eventually(60.seconds) { + 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) diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala index 9f70ca79f4..ef59b2dd71 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/DevelopmentFundFrontendTimeBasedIntegrationTest.scala @@ -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 @@ -250,6 +252,16 @@ 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" @@ -257,13 +269,31 @@ class DevelopmentFundFrontendTimeBasedIntegrationTest 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""" + } + } } } @@ -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") From 860446c52b0247b6c3720bc7b7949300aad85fad Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Tue, 25 Aug 2026 08:26:12 +0000 Subject: [PATCH 4/4] [ci] Fix lintng issue Signed-off-by: Zhe Li --- apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts | 3 +-- .../splice/wallet/util/DevelopmentFundCouponUtil.scala | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts index 2e052a853f..528ec76178 100644 --- a/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts +++ b/apps/sv/frontend/src/utils/buildAmuletConfigChanges.ts @@ -48,8 +48,7 @@ export function buildAmuletConfigChanges( }, { fieldName: 'developmentFundManagerBlacklist', - label: - 'Blacklisted development fund managers (comma-separated party ids)', + label: 'Blacklisted development fund managers (comma-separated party ids)', currentValue: before?.developmentFundManagerBlacklist?.join(', ') || '', newValue: after?.developmentFundManagerBlacklist?.join(', ') || '', }, diff --git a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala index aa7ccc78bb..6482e5736f 100644 --- a/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala +++ b/apps/wallet/src/main/scala/org/lfdecentralizedtrust/splice/wallet/util/DevelopmentFundCouponUtil.scala @@ -1,4 +1,4 @@ -// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// 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.util