Skip to content
Closed
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 @@ -102,6 +102,18 @@ trait PackageVersionSupport {
)
}

def supportsDescriptionInTransferPreapprovals(parties: Seq[PartyId], now: CantonTimestamp)(
implicit tc: TraceContext
): Future[FeatureSupport] = {
isDarSupported(
parties,
PackageIdResolver.Package.SpliceWallet,
now,
// this is when the description field was added to transfer preapprovals
DarResources.wallet_0_1_9,
)
}

private def isDarSupported(
parties: Seq[PartyId],
packageId: PackageIdResolver.Package,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,11 @@ class ValidatorApp(
com.google.protobuf.duration.Duration
.toJavaProto(DurationConversion.toProto(config.deduplicationDuration.asJavaApproximation))
)
synchronizerId <- scanConnection.getAmuletRulesDomain()(traceContext)
packageVersionSupport = PackageVersionSupport.createPackageVersionSupport(
synchronizerId,
readOnlyLedgerConnection,
)
walletManagerOpt =
if (config.enableWallet) {
val externalPartyWalletManager = new ExternalPartyWalletManager(
Expand Down Expand Up @@ -777,6 +782,7 @@ class ValidatorApp(
storage: Storage,
retryProvider,
scanConnection,
packageVersionSupport,
loggerFactory,
domainMigrationInfo,
participantId,
Expand All @@ -794,7 +800,6 @@ class ValidatorApp(
logger.info("Not starting wallet as it's disabled")
None
}
synchronizerId <- scanConnection.getAmuletRulesDomain()(traceContext)
automation = new ValidatorAutomationService(
config.automation,
config.participantIdentitiesBackup,
Expand Down Expand Up @@ -830,10 +835,7 @@ class ValidatorApp(
config.contactPoint,
initialSynchronizerTime,
loggerFactory,
packageVersionSupport = PackageVersionSupport.createPackageVersionSupport(
synchronizerId,
readOnlyLedgerConnection,
),
packageVersionSupport,
)
_ <- config.appInstances.toList.traverse({ case (name, instance) =>
appInitStep(s"Set up app instance $name") {
Expand Down Expand Up @@ -889,6 +891,11 @@ class ValidatorApp(
loggerFactory,
)

packageVersionSupport = PackageVersionSupport.createPackageVersionSupport(
synchronizerId,
readOnlyLedgerConnection,
)

adminHandler =
new HttpValidatorAdminHandler(
automation,
Expand All @@ -899,17 +906,13 @@ class ValidatorApp(
getAmuletRulesDomain = scanConnection.getAmuletRulesDomain,
scanConnection = scanConnection,
participantAdminConnection,
packageVersionSupport,
config,
clock,
retryProvider = retryProvider,
loggerFactory,
)

packageVersionSupport = PackageVersionSupport.createPackageVersionSupport(
synchronizerId,
readOnlyLedgerConnection,
)

walletInternalHandler = walletManagerOpt.map(walletManager =>
new HttpWalletHandler(
walletManager,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import org.lfdecentralizedtrust.splice.codegen.java.splice.wallet.install.amulet
import org.lfdecentralizedtrust.splice.environment.ledger.api.DedupOffset
import org.lfdecentralizedtrust.splice.environment.{
BaseLedgerConnection,
PackageVersionSupport,
ParticipantAdminConnection,
RetryFor,
RetryProvider,
Expand Down Expand Up @@ -73,6 +74,7 @@ class HttpValidatorAdminHandler(
getAmuletRulesDomain: GetAmuletRulesDomain,
scanConnection: ScanConnection,
participantAdminConnection: ParticipantAdminConnection,
packageVersionSupport: PackageVersionSupport,
config: ValidatorAppBackendConfig,
clock: Clock,
retryProvider: RetryProvider,
Expand Down Expand Up @@ -761,6 +763,12 @@ class HttpValidatorAdminHandler(
}
}
externalPartyAmuletRules <- scanConnection.getExternalPartyAmuletRules()
supportsDescription <- packageVersionSupport
.supportsDescriptionInTransferPreapprovals(
Seq(receiverParty, senderParty, store.key.dsoParty),
clock.now,
)
.map(_.supported)
commands = externalPartyAmuletRules.toAssignedContract
.getOrElse(
throw Status.Code.FAILED_PRECONDITION.toStatus
Expand All @@ -777,7 +785,7 @@ class HttpValidatorAdminHandler(
body.amount.bigDecimal,
body.expiresAt.toInstant,
body.nonce,
body.description.toJava,
Option.when(supportsDescription)(body.description).flatten.toJava,
)
)
.update
Expand Down
147 changes: 138 additions & 9 deletions apps/wallet/frontend/src/__tests__/wallet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,17 @@ const dsoEntry = nameServiceEntries.find(e => e.name.startsWith('dso'))!;

const walletUrl = window.splice_config.services.validator.url;

function featureSupportHandler(tokenStandardSupported: boolean) {
function featureSupportHandler(
tokenStandardSupported: boolean,
transferPreapprovalDescriptionSupported: boolean
) {
return rest.get(`${walletUrl}/v0/feature-support`, async (_, res, ctx) => {
return res(ctx.json({ token_standard: tokenStandardSupported }));
return res(
ctx.json({
token_standard: tokenStandardSupported,
transfer_preapproval_description: transferPreapprovalDescriptionSupported,
})
);
});
}

Expand Down Expand Up @@ -100,7 +108,7 @@ describe('Wallet user can', () => {
});

test('not see dso in list of transfer-offer receivers', async () => {
server.use(featureSupportHandler(true));
server.use(featureSupportHandler(true, true));
const user = userEvent.setup();
render(
<WalletConfigProvider>
Expand Down Expand Up @@ -133,7 +141,7 @@ describe('Wallet user can', () => {
transferTests(false);

test('fall back to non-token standard transfers when the token standard is not supported', async () => {
server.use(featureSupportHandler(false));
server.use(featureSupportHandler(false, true));

const user = userEvent.setup();
render(
Expand Down Expand Up @@ -192,6 +200,121 @@ describe('Wallet user can', () => {
// The withdraw has a dummy conversion rate of 0 so no amulet conversion rate is displayed
expect(await screen.findAllByText('@')).toHaveLength(3);
});

test('transfer preapproval (without token standard) does not show nor send description if not supported', async () => {
// token standard as not supported
server.use(featureSupportHandler(false, false));
const user = userEvent.setup();
render(
<WalletConfigProvider>
<App />
</WalletConfigProvider>
);
expect(await screen.findByText('Transfer')).toBeDefined();

const transferOffersLink = screen.getByRole('link', { name: 'Transfer' });
await user.click(transferOffersLink);
expect(screen.getByRole('heading', { name: 'Transfers' })).toBeDefined();

const receiverInput = screen
.getAllByRole('combobox')
.find(e => e.id === 'create-offer-receiver')!;
fireEvent.change(receiverInput, { target: { value: 'bob::preapproval' } });
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled());

// there should be no description input
expect(screen.queryByRole('textbox', { name: 'description' })).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Send' }));

await assertCorrectMockIsCalled(
true,
{
amount: '1.0',
receiver_party_id: 'bob::preapproval',
// description omitted: it is not sent
},
true
);
});

test('transfer offers have description field when unsupported for preapprovals', async () => {
// transfer preapprovals do not support description, but that's inconsequential to regular transfer offers
server.use(featureSupportHandler(false, false));
const user = userEvent.setup();
render(
<WalletConfigProvider>
<App />
</WalletConfigProvider>
);
expect(await screen.findByText('Transfer')).toBeDefined();

const transferOffersLink = screen.getByRole('link', { name: 'Transfer' });
await user.click(transferOffersLink);
expect(screen.getByRole('heading', { name: 'Transfers' })).toBeDefined();

const receiverInput = screen
.getAllByRole('combobox')
.find(e => e.id === 'create-offer-receiver')!;
fireEvent.change(receiverInput, { target: { value: 'bob::preapproval' } });
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled());

await user.click(screen.getByRole('checkbox'));

const description = 'Works';
const descriptionInput = screen.getByRole('textbox', { name: 'description' });
await user.type(descriptionInput, description);

await user.click(screen.getByRole('button', { name: 'Send' }));

await assertCorrectMockIsCalled(
true,
{
amount: '1.0',
receiver_party_id: 'bob::preapproval',
description,
},
false
);
});

test('transfer offers have description field when party has no preapproval and transfer preapprovals do not support descriptions', async () => {
// transfer preapprovals do not support description, but that's inconsequential to regular transfer offers
server.use(featureSupportHandler(false, false));
const user = userEvent.setup();
render(
<WalletConfigProvider>
<App />
</WalletConfigProvider>
);
expect(await screen.findByText('Transfer')).toBeDefined();

const transferOffersLink = screen.getByRole('link', { name: 'Transfer' });
await user.click(transferOffersLink);
expect(screen.getByRole('heading', { name: 'Transfers' })).toBeDefined();

const receiverInput = screen
.getAllByRole('combobox')
.find(e => e.id === 'create-offer-receiver')!;
fireEvent.change(receiverInput, { target: { value: 'bob::nopreapproval' } });
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Send' })).toBeEnabled());

const description = 'Works';
const descriptionInput = screen.getByRole('textbox', { name: 'description' });
await user.type(descriptionInput, description);

await user.click(screen.getByRole('button', { name: 'Send' }));

await assertCorrectMockIsCalled(
true,
{
amount: '1.0',
receiver_party_id: 'bob::nopreapproval',
description,
},
false
);
});
}, 7500);

function transferTests(disableTokenStandard: boolean) {
Expand All @@ -203,7 +326,7 @@ function transferTests(disableTokenStandard: boolean) {
}

test('transfer offer is used when receiver has no transfer preapproval', async () => {
server.use(featureSupportHandler(true));
server.use(featureSupportHandler(true, true));
const user = userEvent.setup();
render(
<WalletConfigProvider>
Expand Down Expand Up @@ -236,7 +359,7 @@ function transferTests(disableTokenStandard: boolean) {
});

test('transfer preapproval is used when receiver has a transfer preapproval', async () => {
server.use(featureSupportHandler(true));
server.use(featureSupportHandler(true, true));
const user = userEvent.setup();
render(
<WalletConfigProvider>
Expand Down Expand Up @@ -270,7 +393,7 @@ function transferTests(disableTokenStandard: boolean) {
});

test('transfer offer is used when receiver has a transfer preapproval but checkbox is unchecked', async () => {
server.use(featureSupportHandler(true));
server.use(featureSupportHandler(true, true));
const user = userEvent.setup();
render(
<WalletConfigProvider>
Expand Down Expand Up @@ -304,7 +427,7 @@ function transferTests(disableTokenStandard: boolean) {
});

test('deduplication id is passed', async () => {
server.use(featureSupportHandler(true));
server.use(featureSupportHandler(true, true));
const user = userEvent.setup();
render(
<WalletConfigProvider>
Expand Down Expand Up @@ -370,7 +493,7 @@ function transferTests(disableTokenStandard: boolean) {

async function assertCorrectMockIsCalled(
usesRegularTransferOffer: boolean,
expected: { amount: string; receiver_party_id: string; description: string },
expected: { amount: string; receiver_party_id: string; description?: string },
isPreapproval: boolean
) {
if (!usesRegularTransferOffer) {
Expand All @@ -383,6 +506,12 @@ async function assertCorrectMockIsCalled(
expect(requestMocks.transferPreapprovalSend).toHaveBeenCalledWith(
expect.objectContaining(expected)
);
// unfortunately 'objectContaining' does not work for `description: undefined`:
// the mock omits the field and the expected has it as undefined.
// Omitting `description` from `expected` doesn't work because then it's not checked at all.
expect(requestMocks.transferPreapprovalSend.mock.lastCall![0].description).equals(
expected.description
);
expect(requestMocks.createTransferOffer).not.toHaveBeenCalled();
expect(requestMocks.createTransferViaTokenStandard).not.toHaveBeenCalled();
} else {
Expand Down
28 changes: 16 additions & 12 deletions apps/wallet/frontend/src/components/SendTransfer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,22 @@ const SendTransfer: React.FC = () => {
</FormControl>
</Stack>
)}
<Stack direction="column" mb={4} spacing={1}>
<Typography variant="h6">
Description <Typography variant="caption">(optional)</Typography>{' '}
</Typography>
<TextField
id="create-offer-description"
rows={4}
multiline
inputProps={{ 'aria-label': 'description' }}
onChange={e => setDescription(e.target.value)}
/>
</Stack>
{featureSupport.data?.transferPreapprovalDescription ||
!useTransferPreapproval ||
!preapprovalResult.data ? (
<Stack direction="column" mb={4} spacing={1}>
<Typography variant="h6">
Description <Typography variant="caption">(optional)</Typography>{' '}
</Typography>
<TextField
id="create-offer-description"
rows={4}
multiline
inputProps={{ 'aria-label': 'description' }}
onChange={e => setDescription(e.target.value)}
/>
</Stack>
) : null}

<DisableConditionally
conditions={[
Expand Down
2 changes: 1 addition & 1 deletion apps/wallet/frontend/src/contexts/WalletServiceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export const WalletClientProvider: React.FC<React.PropsWithChildren<WalletProps>
receiver_party_id: receiverPartyId,
amount: amount.isInteger() ? amount.toFixed(1) : amount.toString(),
deduplication_id: deduplicationId,
description,
description: description === '' ? undefined : description,
};
await walletClient.transferPreapprovalSend(request);
},
Expand Down
Loading
Loading