diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AllocationsFrontendIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AllocationsFrontendIntegrationTest.scala index d9a3b1769b..50d61ce7a8 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AllocationsFrontendIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/AllocationsFrontendIntegrationTest.scala @@ -256,7 +256,40 @@ class AllocationsFrontendIntegrationTest }, ) - // TODO (#4915): test withdraw and reject like in the test below + val allocationRequestElement = clue("find the allocation request element") { + eventually() { + findAll(className("allocation-request")).toSeq.loneElement + } + } + + actAndCheck( + "click on withdrawing the allocation", { + val allocationElement = findAll(className("allocation")).toSeq.loneElement + click on allocationElement + .findChildElement(className("allocation-withdraw")) + .valueOrFail("Could not find withdraw button for allocation") + }, + )( + "the allocation is not shown anymore", + _ => { + findAll(className("allocation")).toSeq shouldBe empty withClue "Allocation Cards" + }, + ) + + actAndCheck( + "click on rejecting the allocation request", { + click on allocationRequestElement + .findChildElement(className("allocation-request-reject")) + .valueOrFail("Could not find reject button for allocation request") + }, + )( + "the allocation request is not shown anymore", + _ => { + findAll( + className("allocation-request") + ).toSeq shouldBe empty withClue "Allocation Request Cards" + }, + ) } } diff --git a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardFetchFallbackIntegrationTest.scala b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardFetchFallbackIntegrationTest.scala index dca28248e4..74e18315f2 100644 --- a/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardFetchFallbackIntegrationTest.scala +++ b/apps/app/src/test/scala/org/lfdecentralizedtrust/splice/integration/tests/TokenStandardFetchFallbackIntegrationTest.scala @@ -3,7 +3,12 @@ package org.lfdecentralizedtrust.splice.integration.tests import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.{HasActorSystem, HasExecutionContext} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.allocationv1.* +import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.{allocationv2, metadatav1} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.holdingv1.InstrumentId +import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.holdingv2.{ + Account as AccountV2, + InstrumentId as InstrumentIdV2, +} import org.lfdecentralizedtrust.splice.codegen.java.splice.api.token.transferinstructionv1.TransferInstruction import org.lfdecentralizedtrust.splice.http.v0.definitions.TransferInstructionResultOutput.members import org.lfdecentralizedtrust.splice.integration.EnvironmentDefinition @@ -127,14 +132,66 @@ class TokenStandardFetchFallbackIntegrationTest }, ) - // TODO (#4915): check that the fallback also works for V2 - clue("SV-1's Scan sees it (still, even though ingestion is paused)") { + clue("SV-1's Scan sees V1 allocation (still, even though ingestion is paused)") { eventuallySucceeds() { sv1ScanBackend.getAllocationTransferContext( allocation.contract.contractId.toInterface(Allocation.INTERFACE) ) } } + + val referenceIdV2 = UUID.randomUUID().toString + + val (_, allocationV2) = actAndCheck( + "Alice creates a V2 Allocation", + aliceWalletClient.allocateAmulet( + new allocationv2.AllocationSpecification( + new allocationv2.SettlementInfo( + java.util.List.of(dsoParty.toProtoPrimitive), + new allocationv2.Reference(referenceIdV2, Optional.empty), + Instant.now, + Instant.now.plusSeconds(3600L), + Optional.of(Instant.now.plusSeconds(2 * 3600L)), + new metadatav1.Metadata(java.util.Map.of()), + ), + java.util.List.of( + new allocationv2.TransferLeg( + UUID.randomUUID().toString, + new AccountV2(aliceParty.toProtoPrimitive, Optional.empty(), ""), + new AccountV2(bobParty.toProtoPrimitive, Optional.empty(), ""), + BigDecimal(10).bigDecimal, + new InstrumentIdV2(dsoParty.toProtoPrimitive, "Amulet"), + new metadatav1.Metadata(java.util.Map.of()), + ) + ), + new AccountV2(aliceParty.toProtoPrimitive, Optional.empty(), ""), + ) + ), + )( + "Alice sees the V2 Allocation", + _ => { + val alloc = inside(aliceWalletClient.listAmuletAllocations()) { + case _ :+ (v2Alloc: HttpWalletAppClient.TokenStandard.V2AmuletAllocation) => + v2Alloc + } + alloc.contract.payload.allocation.settlement.settlementRef.id should be( + referenceIdV2 + ) + alloc + }, + ) + + clue( + "SV-1's Scan sees V2 allocation cancel context (still, even though ingestion is paused)" + ) { + eventuallySucceeds() { + sv1ScanBackend.getAllocationV2CancelContext( + allocationV2.contract.contractId.toInterface( + allocationv2.Allocation.INTERFACE + ) + ) + } + } } } diff --git a/apps/wallet/frontend/src/__tests__/wallet.test.tsx b/apps/wallet/frontend/src/__tests__/wallet.test.tsx index 176074d1c7..77b61d235f 100644 --- a/apps/wallet/frontend/src/__tests__/wallet.test.tsx +++ b/apps/wallet/frontend/src/__tests__/wallet.test.tsx @@ -34,6 +34,7 @@ import { AllocateAmuletResponse as AllocateAmuletV1Response, AllocateAmuletV2Request, AllocateAmuletV2Response, + AmuletAllocationV2WithdrawResult, AmuletAllocationWithdrawResult, ChoiceExecutionMetadata, ListAllocationRequestsResponse, @@ -53,6 +54,7 @@ import { AnyContract } from '@daml.js/splice-api-token-metadata/lib/Splice/Api/T import { AmuletAllocationV2 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocationV2'; import { AmuletAllocation as AmuletAllocationV1 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocation'; import { Contract } from '@lfdecentralizedtrust/splice-common-frontend-utils'; +import { LockedAmulet } from '@daml.js/splice-amulet/lib/Splice/Amulet'; const dsoEntry = nameServiceEntries.find(e => e.name.startsWith('dso'))!; @@ -232,7 +234,7 @@ describe('Wallet user can', () => { describe('Allocations', () => { test('see allocations', async () => { const allocations = Array.from({ length: 3 }, (_, i) => - getAllocation( + getAllocationV2( `settlement_${i}`, `transfer_leg_${i}`, `receiver_${i}::party`, @@ -438,14 +440,13 @@ describe('Wallet user can', () => { expect(calledWithBody).toStrictEqual(expected); }); - // TODO (#4915): implement this test for v2 test("withdraw allocations v1 from the allocation or the allocation request's leg views", async () => { const allocationRequestPayload = getAllocationRequestV1(); const allocationRequest = mkContract(AllocationRequestV1, allocationRequestPayload); const allocationRequests = [allocationRequest]; const allocation = mkContract( AmuletAllocationV1, - getAllocation( + getAllocationV1( allocationRequestPayload.settlement.settlementRef.id, 'acceptable', allocationRequestPayload.transferLegs.acceptable.receiver, @@ -508,16 +509,93 @@ describe('Wallet user can', () => { expect(await screen.findByLabelText(`Allocations ${allocations.length}`)).toBeDefined(); const withdrawButtons = await screen.findAllByRole('button', { name: 'Withdraw' }); - expect(withdrawButtons).to.have.length(1); + expect(withdrawButtons).to.have.length(2); for (const button of withdrawButtons) { await user.click(button); } - expect(calledWithdrawArgs).toStrictEqual([allocation.contract_id]); + expect(calledWithdrawArgs).toStrictEqual([allocation.contract_id, allocation.contract_id]); + }); + + test("withdraw allocations v2 from the allocation or the allocation request's views", async () => { + const allocationRequestPayload = getAllocationRequestV2(); + const allocationRequest = mkContract(AllocationRequestV2, allocationRequestPayload); + const allocationRequests = [allocationRequest]; + const allocation = mkContract( + AmuletAllocationV2, + getAllocationV2( + allocationRequestPayload.settlement.settlementRef.id, + 'acceptable', + bobPartyId, + '3', + allocationRequestPayload.settlement.executors[0] + ) + ); + const allocations = [allocation]; + + const calledWithdrawArgs: string[] = []; + + server.use( + rest.get( + `${walletUrl}/v0/wallet/token-standard/allocation-requests`, + (_req, res, ctx) => { + return res( + ctx.json({ + allocation_requests: allocationRequests.map(contract => { + return { contract }; + }), + }) + ); + } + ), + rest.get(`${walletUrl}/v0/allocations`, (_req, res, ctx) => { + return res( + ctx.json({ + allocations: allocations.map(contract => { + return { contract }; + }), + }) + ); + }), + rest.post(`${walletUrl}/v2/allocations/:cid/withdraw`, (req, res, ctx) => { + calledWithdrawArgs.push(req.params.cid.toString()); + return res( + ctx.json({ + authorizer_holding_cids: {}, + meta: {}, + }) + ); + }) + ); + + const user = userEvent.setup(); + render( + + + + ); + expect(await screen.findByText('Allocations')).toBeDefined(); + const allocationsLink = screen.getByRole('link', { name: 'Allocations' }); + await user.click(allocationsLink); + + // there should be one allocation request and one allocation, + // both of which with a withdraw button + expect( + await screen.findByLabelText(`Allocation Requests ${allocationRequests.length}`) + ).toBeDefined(); + expect(await screen.findByLabelText(`Allocations ${allocations.length}`)).toBeDefined(); + + const withdrawButtons = await screen.findAllByRole('button', { name: 'Withdraw' }); + expect(withdrawButtons).to.have.length(2); + + for (const button of withdrawButtons) { + await user.click(button); + } + + expect(calledWithdrawArgs).toStrictEqual([allocation.contract_id, allocation.contract_id]); }); - // TODO (#4915): implement this test for v2 test('reject allocation requests', async () => { const allocationRequestPayload = getAllocationRequestV1(); const allocationRequest = mkContract(AllocationRequestV1, allocationRequestPayload); @@ -578,6 +656,67 @@ describe('Wallet user can', () => { expect(calledRejectArgs).toStrictEqual([allocationRequest.contract_id]); }); + + test('reject allocation requests v2', async () => { + const allocationRequestPayload = getAllocationRequestV2(); + const allocationRequest = mkContract(AllocationRequestV2, allocationRequestPayload); + const allocationRequests = [allocationRequest]; + + const calledRejectArgs: string[] = []; + + server.use( + rest.get( + `${walletUrl}/v0/wallet/token-standard/allocation-requests`, + (_req, res, ctx) => { + return res( + ctx.json({ + allocation_requests: allocationRequests.map(contract => { + return { contract }; + }), + }) + ); + } + ), + rest.get(`${walletUrl}/v0/allocations`, (_req, res, ctx) => { + return res( + ctx.json({ + allocations: [], + }) + ); + }), + rest.post( + `${walletUrl}/v0/wallet/token-standard/allocation-requests/:cid/reject`, + (req, res, ctx) => { + calledRejectArgs.push(req.params.cid.toString()); + return res( + ctx.json({ + meta: {}, + }) + ); + } + ) + ); + + const user = userEvent.setup(); + render( + + + + ); + expect(await screen.findByText('Allocations')).toBeDefined(); + const allocationsLink = screen.getByRole('link', { name: 'Allocations' }); + await user.click(allocationsLink); + + // there should be one allocation request with a reject button + expect( + await screen.findByLabelText(`Allocation Requests ${allocationRequests.length}`) + ).toBeDefined(); + + const rejectButton = await screen.findByRole('button', { name: 'Reject' }); + await user.click(rejectButton); + + expect(calledRejectArgs).toStrictEqual([allocationRequest.contract_id]); + }); }); }); @@ -1365,15 +1504,15 @@ function getAllocationRequestV2() { }; } -function getAllocation( +function getAllocationV2( settlementId: string, transferLegId: string, receiver: string, amount: string, executor: string -) { +): AmuletAllocationV2 { return { - lockedAmulet: null as damlTypes.Optional, + lockedAmulet: null as damlTypes.Optional>, dso: dsoPartyId, expiresAt: new Date().toISOString(), allocation: { @@ -1402,3 +1541,36 @@ function getAllocation( }, }; } + +function getAllocationV1( + settlementId: string, + transferLegId: string, + receiver: string, + amount: string, + executor: string +): AmuletAllocationV1 { + return { + lockedAmulet: `lockedamulet${settlementId}` as ContractId, + allocation: { + transferLegId, + transferLeg: { + sender: alicePartyId, + receiver, + amount, + meta: { values: {} }, + instrumentId: { id: 'Amulet', admin: 'dso::party' }, + }, + settlement: { + executor, + settlementRef: { + id: settlementId, + cid: null, + }, + requestedAt: new Date().toISOString(), + allocateBefore: new Date().toISOString(), + settleBefore: new Date().toISOString(), + meta: { values: {} }, + }, + }, + }; +} diff --git a/apps/wallet/frontend/src/components/ListAllocationRequests.tsx b/apps/wallet/frontend/src/components/ListAllocationRequests.tsx index 535d82b74b..068b994722 100644 --- a/apps/wallet/frontend/src/components/ListAllocationRequests.tsx +++ b/apps/wallet/frontend/src/components/ListAllocationRequests.tsx @@ -8,6 +8,8 @@ import { DisableConditionally, Loading } from '@lfdecentralizedtrust/splice-comm import { AllocationRequest as AllocationRequestV2 } from '@daml.js/splice-api-token-allocation-request-v2/lib/Splice/Api/Token/AllocationRequestV2/module'; import { AllocationRequest as AllocationRequestV1 } from '@daml.js/splice-api-token-allocation-request/lib/Splice/Api/Token/AllocationRequestV1/module'; import { Contract } from '@lfdecentralizedtrust/splice-common-frontend-utils'; +import { AmuletAllocation as AmuletAllocationV1 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocation'; +import { AmuletAllocationV2 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocationV2'; import { usePrimaryParty } from '../hooks'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; @@ -33,6 +35,7 @@ import { import { damlTimestampToOpenApiTimestamp } from '../utils/timestampConversion'; import AllocationSettlementDisplay from './AllocationSettlementDisplay'; import UseGetAmuletRules from '../hooks/scan-proxy/useGetAmuletRules'; +import { ContractId } from '@daml/types'; dayjs.extend(relativeTime); @@ -199,11 +202,13 @@ const V2AllocationRequestActionButton: React.FC<{ // basicAccount check: authorizer matches basicAccount(userParty) const canAccept = amuletLegsForUser.length > 0 && isAuthorizer; - const hasExistingAllocation = allocations.some(alloc => + const correspondingAllocation = allocations.find(alloc => isAllocationForRequest(alloc, allocationRequest) ); - const { createAllocationV2 } = useWalletClient(); + const hasExistingAllocation = !!correspondingAllocation; + + const { createAllocationV2, withdrawAllocationV2 } = useWalletClient(); const createAllocationV2Mutation = useMutation({ mutationFn: async () => { const req = openApiV2RequestFromAllocationRequest(payload.settlement, amuletLegsForUser); @@ -215,8 +220,46 @@ const V2AllocationRequestActionButton: React.FC<{ }, }); - // TODO (#4915): implement withdraw button for v2 when hasExistingAllocation - if (!canAccept || hasExistingAllocation) return null; + const withdrawAllocationV2Mutation = useMutation({ + mutationFn: async () => { + if (correspondingAllocation) { + return await withdrawAllocationV2( + correspondingAllocation.contractId as ContractId + ); + } else { + throw new Error("This mutation shouldn't be called without a corresponding allocation"); + } + }, + onSuccess: () => {}, + onError: error => { + console.error('Failed to withdraw allocation', error); + }, + }); + + if (!canAccept) return null; + + if (hasExistingAllocation) { + return ( + + + + ); + } return ( { if (correspondingAllocation) { - return await withdrawAllocation(correspondingAllocation.contractId); + return await withdrawAllocation( + correspondingAllocation.contractId as ContractId + ); } else { throw new Error("This mutation shouldn't be called without a corresponding allocation"); } diff --git a/apps/wallet/frontend/src/components/ListAllocations.tsx b/apps/wallet/frontend/src/components/ListAllocations.tsx index 314411c22f..6d5553bae5 100644 --- a/apps/wallet/frontend/src/components/ListAllocations.tsx +++ b/apps/wallet/frontend/src/components/ListAllocations.tsx @@ -6,14 +6,18 @@ import { DisableConditionally, Loading } from '@lfdecentralizedtrust/splice-comm import Typography from '@mui/material/Typography'; import { Button, Card, CardContent, Chip, Stack } from '@mui/material'; import { Contract } from '@lfdecentralizedtrust/splice-common-frontend-utils'; -import { AmuletAllocation } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocation'; +import { AmuletAllocation as AmuletAllocationV1 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocation'; import { AmuletAllocationV2 } from '@daml.js/splice-amulet/lib/Splice/AmuletAllocationV2'; import TransferLegsDisplay from './TransferLegsDisplay'; import AllocationSettlementDisplay from './AllocationSettlementDisplay'; import { useMutation } from '@tanstack/react-query'; -import { useWalletClient } from '../contexts/WalletServiceContext'; -import { ContractId } from '@daml/types'; +import { + AmuletAllocation, + isV2Allocation, + useWalletClient, +} from '../contexts/WalletServiceContext'; import { AllocationSpecification } from '@daml.js/splice-api-token-allocation-v2/lib/Splice/Api/Token/AllocationV2/module'; +import { ContractId } from '@daml/types'; const ListAllocations: React.FC = () => { const allocationsQuery = useAmuletAllocations(); @@ -52,7 +56,8 @@ const ListAllocations: React.FC = () => { const AllocationDisplay: React.FC<{ allocation: Contract; }> = ({ allocation }) => { - const v2 = isV2(allocation.payload); + const { withdrawAllocation, withdrawAllocationV2 } = useWalletClient(); + const v2 = isV2Allocation(allocation.payload); const spec = getAllocationSpec(allocation.payload); const { settlement, transferLegs } = spec; return ( @@ -73,10 +78,15 @@ const AllocationDisplay: React.FC<{ - // TODO (#4915): implement withdraw button for v2 when hasExistingAllocation - v2 ? null : - } + getActionButton={() => ( + + v2 + ? withdrawAllocationV2(allocation.contractId as ContractId) + : withdrawAllocation(allocation.contractId as ContractId) + } + /> + )} /> @@ -84,14 +94,8 @@ const AllocationDisplay: React.FC<{ ); }; -function isV2(payload: AmuletAllocation | AmuletAllocationV2): payload is AmuletAllocationV2 { - return 'dso' in payload; -} - -function getAllocationSpec( - payload: AmuletAllocation | AmuletAllocationV2 -): AllocationSpecification { - if (isV2(payload)) { +function getAllocationSpec(payload: AmuletAllocation): AllocationSpecification { + if (isV2Allocation(payload)) { return payload.allocation; } // V1: convert to V2 AllocationSpecification shape @@ -120,12 +124,11 @@ function getAllocationSpec( } const WithdrawAllocationButton: React.FC<{ - allocationCid: ContractId; -}> = ({ allocationCid }) => { - const { withdrawAllocation } = useWalletClient(); + withdrawFn: () => Promise; +}> = ({ withdrawFn }) => { const withdrawAllocationMutation = useMutation({ mutationFn: async () => { - return await withdrawAllocation(allocationCid); + return await withdrawFn(); }, onSuccess: () => {}, onError: error => { diff --git a/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx b/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx index 7d66f97624..505e91f415 100644 --- a/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx +++ b/apps/wallet/frontend/src/contexts/WalletServiceContext.tsx @@ -144,7 +144,8 @@ export interface WalletClient { rejectAllocationRequest: (allocationRequestCid: ContractId) => Promise; createAllocation: (allocateAmuletRequest: AllocateAmuletRequest) => Promise; createAllocationV2: (allocateAmuletV2Request: AllocateAmuletV2Request) => Promise; - withdrawAllocation: (allocationCid: ContractId) => Promise; + withdrawAllocation: (allocationCid: ContractId) => Promise; + withdrawAllocationV2: (allocationCid: ContractId) => Promise; getAppPaymentRequest: (contractId: string) => Promise>; acceptAppPaymentRequest: (requestContractId: string) => Promise; @@ -432,6 +433,9 @@ export const WalletClientProvider: React.FC withdrawAllocation: async allocationCid => { await walletClient.withdrawAmuletAllocation(allocationCid); }, + withdrawAllocationV2: async allocationCid => { + await walletClient.withdrawAmuletAllocationV2(allocationCid); + }, getAppPaymentRequest: async contractId => { const response = await walletClient.getAppPaymentRequest(contractId); const contract = Contract.decodeOpenAPI(response.contract, payment.AppPaymentRequest);