Created
May 26, 2026 15:30
-
-
Save fayimora/55102885d1a38359651a101892adb83c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. | |
| // SPDX-License-Identifier: Apache-2.0 | |
| import { useMemo, useState } from 'react' | |
| import { | |
| Alert, | |
| Box, | |
| Button, | |
| Card, | |
| CardContent, | |
| Checkbox, | |
| CircularProgress, | |
| Divider, | |
| FormControl, | |
| FormHelperText, | |
| InputLabel, | |
| List, | |
| ListItem, | |
| ListItemButton, | |
| ListItemIcon, | |
| ListItemText, | |
| MenuItem, | |
| Paper, | |
| Select, | |
| TextField, | |
| Typography, | |
| } from '@mui/material' | |
| import { useForm } from '@tanstack/react-form' | |
| import type { AssetBody } from '@canton-network/wallet-sdk' | |
| import type { PartyId } from '@canton-network/core-types' | |
| import { z } from 'zod' | |
| import { toast } from 'sonner' | |
| import { useRegistryUrls } from '@contexts/RegistryServiceContext' | |
| import { useWalletSdk } from '../hooks/useWalletSdk' | |
| import { usePrimaryAccount } from '@hooks/useAccounts' | |
| type PreapprovalFormData = { | |
| registryUrl: string | |
| operatorParty: string | |
| selectedAssetKeys: string[] | |
| } | |
| type CreatePreapprovalContractsInput = { | |
| receiver: PartyId | |
| operatorParty: PartyId | |
| instrumentAdmin: PartyId | |
| assets: AssetBody[] | |
| } | |
| const createPreapprovalContracts = async ({ | |
| receiver, | |
| operatorParty, | |
| instrumentAdmin, | |
| assets, | |
| }: CreatePreapprovalContractsInput): Promise<void> => { | |
| // TODO: implement me. Use the lib from utilities | |
| void receiver | |
| void operatorParty | |
| void instrumentAdmin | |
| void assets | |
| } | |
| const registryUrlValidator = z.string().min(1, 'Select a registry') | |
| const operatorPartyValidator = z | |
| .string() | |
| .trim() | |
| .min(1, 'Operator party is required') | |
| const selectedAssetsValidator = z | |
| .array(z.string()) | |
| .min(1, 'Select at least one asset') | |
| const getAssetKey = (asset: AssetBody) => | |
| `${asset.admin}::${asset.id}::${asset.registryUrl.toString()}` | |
| const formatRegistryOption = (partyId: string, registryUrl: string) => | |
| `${partyId} — ${registryUrl}` | |
| export function PreapprovalContractSettings() { | |
| const registryUrls = useRegistryUrls() | |
| const primaryParty = usePrimaryAccount()?.partyId | |
| const { | |
| sdk, | |
| isLoading: isWalletSdkLoading, | |
| error: walletSdkError, | |
| refresh, | |
| } = useWalletSdk() | |
| const [fetchedAssets, setFetchedAssets] = useState<AssetBody[]>([]) | |
| const [hasFetchedAssets, setHasFetchedAssets] = useState(false) | |
| const registryOptions = useMemo( | |
| () => Array.from(registryUrls.entries()), | |
| [registryUrls] | |
| ) | |
| const form = useForm({ | |
| defaultValues: { | |
| registryUrl: '', | |
| operatorParty: '', | |
| selectedAssetKeys: [], | |
| } as PreapprovalFormData, | |
| onSubmit: async ({ value: formData }) => { | |
| if (!primaryParty) { | |
| toast.error('Primary party is unavailable') | |
| return | |
| } | |
| if (!sdk) { | |
| toast.error('Wallet SDK is not ready') | |
| return | |
| } | |
| const selectedAssets = fetchedAssets.filter((asset) => | |
| formData.selectedAssetKeys.includes(getAssetKey(asset)) | |
| ) | |
| // if (selectedAssets.length === 0) { | |
| // toast.error('Select at least one asset') | |
| // return | |
| // } | |
| // const instrumentAdmins = new Set( | |
| // selectedAssets.map((asset) => asset.admin) | |
| // ) | |
| // if (instrumentAdmins.size !== 1) { | |
| // toast.error( | |
| // 'Selected assets must belong to one instrument admin' | |
| // ) | |
| // return | |
| // } | |
| // const registryStillConfigured = registryOptions.some( | |
| // ([, registryUrl]) => registryUrl === formData.registryUrl | |
| // ) | |
| // if (!registryStillConfigured) { | |
| // toast.error('Selected registry is no longer configured') | |
| // return | |
| // } | |
| await createPreapprovalContracts({ | |
| receiver: primaryParty, | |
| operatorParty: formData.operatorParty.trim(), | |
| instrumentAdmin: selectedAssets[0].admin, | |
| assets: selectedAssets, | |
| }) | |
| toast.success('Mock Preapproval contract created') | |
| }, | |
| }) | |
| const handleFetchAssets = () => { | |
| const registryUrl = form.getFieldValue('registryUrl') | |
| if (!sdk) { | |
| toast.error('Wallet SDK is not ready') | |
| return | |
| } | |
| console.log('registryUrl', registryUrl) | |
| const normalizedRegistryUrl = new URL(registryUrl).toString() | |
| console.log('normalizedRegistryUrl', normalizedRegistryUrl) | |
| console.log('sdk.asset.list', sdk.asset.list) | |
| const assets = sdk.asset.list.filter( | |
| (asset) => asset.registryUrl.toString() === normalizedRegistryUrl | |
| ) | |
| setFetchedAssets(assets) | |
| setHasFetchedAssets(true) | |
| form.setFieldValue('selectedAssetKeys', []) | |
| toast.success( | |
| assets.length > 0 | |
| ? `Fetched ${assets.length} asset${assets.length === 1 ? '' : 's'}` | |
| : 'No assets found for this registry' | |
| ) | |
| } | |
| const handleToggleAll = (selectedAssetKeys: string[]) => { | |
| const allSelected = | |
| fetchedAssets.length > 0 && | |
| fetchedAssets.every((asset) => | |
| selectedAssetKeys.includes(getAssetKey(asset)) | |
| ) | |
| form.setFieldValue( | |
| 'selectedAssetKeys', | |
| allSelected ? [] : fetchedAssets.map(getAssetKey) | |
| ) | |
| } | |
| const noRegistries = registryOptions.length === 0 | |
| const fetchDisabled = noRegistries || !sdk || isWalletSdkLoading | |
| return ( | |
| <Card sx={{ mb: 3 }}> | |
| <CardContent> | |
| <Typography variant="h6" component="h2" gutterBottom> | |
| Preapproval Contracts | |
| </Typography> | |
| <Typography | |
| variant="body2" | |
| color="text.secondary" | |
| sx={{ mb: 3 }} | |
| > | |
| Create preapproval contracts for selected assets from a | |
| configured registry. | |
| </Typography> | |
| {!primaryParty && ( | |
| <Alert severity="info" sx={{ mb: 2 }}> | |
| Connect a wallet to create preapproval contracts. | |
| </Alert> | |
| )} | |
| {isWalletSdkLoading && ( | |
| <Alert severity="info" sx={{ mb: 2 }}> | |
| Preparing wallet SDK… | |
| </Alert> | |
| )} | |
| {walletSdkError && ( | |
| <Alert | |
| severity="error" | |
| action={ | |
| <Button | |
| color="inherit" | |
| size="small" | |
| onClick={refresh} | |
| > | |
| Retry | |
| </Button> | |
| } | |
| sx={{ mb: 2 }} | |
| > | |
| Wallet SDK failed to initialize: {walletSdkError} | |
| </Alert> | |
| )} | |
| {noRegistries && ( | |
| <Alert severity="warning" sx={{ mb: 2 }}> | |
| Add a registry above before creating preapproval | |
| contracts. | |
| </Alert> | |
| )} | |
| <Box | |
| component="form" | |
| onSubmit={(event) => { | |
| event.preventDefault() | |
| event.stopPropagation() | |
| form.handleSubmit() | |
| }} | |
| > | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 3, | |
| }} | |
| > | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: 2, | |
| }} | |
| > | |
| <form.Field | |
| name="registryUrl" | |
| validators={{ | |
| onChange: registryUrlValidator, | |
| onSubmit: registryUrlValidator, | |
| }} | |
| > | |
| {(field) => ( | |
| <FormControl | |
| fullWidth | |
| size="small" | |
| error={ | |
| field.state.meta.isTouched && | |
| field.state.meta.errors.length > 0 | |
| } | |
| disabled={noRegistries} | |
| > | |
| <InputLabel id="preapproval-registry-label"> | |
| Registry | |
| </InputLabel> | |
| <Select | |
| labelId="preapproval-registry-label" | |
| label="Registry" | |
| value={field.state.value} | |
| onChange={(event) => { | |
| field.handleChange( | |
| event.target.value | |
| ) | |
| setFetchedAssets([]) | |
| setHasFetchedAssets(false) | |
| form.setFieldValue( | |
| 'selectedAssetKeys', | |
| [] | |
| ) | |
| }} | |
| onBlur={field.handleBlur} | |
| > | |
| {registryOptions.map( | |
| ([partyId, registryUrl]) => ( | |
| <MenuItem | |
| key={partyId} | |
| value={registryUrl} | |
| > | |
| {formatRegistryOption( | |
| partyId, | |
| registryUrl | |
| )} | |
| </MenuItem> | |
| ) | |
| )} | |
| </Select> | |
| <FormHelperText> | |
| {field.state.meta.isTouched && | |
| field.state.meta.errors.length > 0 | |
| ? field.state.meta.errors[0] | |
| ?.message | |
| : noRegistries | |
| ? 'No registries configured' | |
| : ''} | |
| </FormHelperText> | |
| </FormControl> | |
| )} | |
| </form.Field> | |
| <form.Field | |
| name="operatorParty" | |
| validators={{ | |
| onChange: operatorPartyValidator, | |
| onSubmit: operatorPartyValidator, | |
| }} | |
| > | |
| {(field) => ( | |
| <TextField | |
| label="Operator Party" | |
| value={field.state.value} | |
| onChange={(event) => | |
| field.handleChange( | |
| event.target.value | |
| ) | |
| } | |
| onBlur={field.handleBlur} | |
| error={ | |
| field.state.meta.isTouched && | |
| field.state.meta.errors.length > 0 | |
| } | |
| helperText={ | |
| field.state.meta.isTouched && | |
| field.state.meta.errors.length > 0 | |
| ? field.state.meta.errors[0] | |
| ?.message | |
| : '' | |
| } | |
| fullWidth | |
| size="small" | |
| /> | |
| )} | |
| </form.Field> | |
| <Button | |
| type="button" | |
| variant="contained" | |
| onClick={handleFetchAssets} | |
| disabled={fetchDisabled} | |
| sx={{ | |
| alignSelf: 'flex-start', | |
| minWidth: 160, | |
| minHeight: 40, | |
| }} | |
| > | |
| {isWalletSdkLoading ? ( | |
| <CircularProgress size={20} /> | |
| ) : ( | |
| 'Fetch assets' | |
| )} | |
| </Button> | |
| </Box> | |
| {hasFetchedAssets && ( | |
| <> | |
| <Divider /> | |
| <form.Field | |
| name="selectedAssetKeys" | |
| validators={{ | |
| onChange: selectedAssetsValidator, | |
| onSubmit: selectedAssetsValidator, | |
| }} | |
| > | |
| {(field) => { | |
| const selectedAssetKeys = | |
| field.state.value | |
| const selectedCount = | |
| selectedAssetKeys.length | |
| const allSelected = | |
| fetchedAssets.length > 0 && | |
| fetchedAssets.every((asset) => | |
| selectedAssetKeys.includes( | |
| getAssetKey(asset) | |
| ) | |
| ) | |
| return ( | |
| <Box> | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| justifyContent: | |
| 'space-between', | |
| alignItems: 'center', | |
| gap: 2, | |
| mb: 1, | |
| }} | |
| > | |
| <Typography variant="subtitle1"> | |
| Assets | |
| </Typography> | |
| <Box | |
| sx={{ | |
| display: 'flex', | |
| alignItems: | |
| 'center', | |
| gap: 2, | |
| }} | |
| > | |
| <Typography | |
| variant="body2" | |
| color="text.secondary" | |
| > | |
| {selectedCount} of{' '} | |
| { | |
| fetchedAssets.length | |
| }{' '} | |
| selected | |
| </Typography> | |
| <Button | |
| type="button" | |
| size="small" | |
| onClick={() => | |
| handleToggleAll( | |
| selectedAssetKeys | |
| ) | |
| } | |
| disabled={ | |
| fetchedAssets.length === | |
| 0 | |
| } | |
| > | |
| {allSelected | |
| ? 'Clear all' | |
| : 'Toggle all'} | |
| </Button> | |
| </Box> | |
| </Box> | |
| {fetchedAssets.length > 0 ? ( | |
| <Paper | |
| variant="outlined" | |
| sx={{ | |
| maxHeight: 320, | |
| overflow: 'auto', | |
| }} | |
| > | |
| <List disablePadding> | |
| {fetchedAssets.map( | |
| (asset) => { | |
| const key = | |
| getAssetKey( | |
| asset | |
| ) | |
| const checked = | |
| selectedAssetKeys.includes( | |
| key | |
| ) | |
| return ( | |
| <ListItem | |
| key={ | |
| key | |
| } | |
| disablePadding | |
| > | |
| <ListItemButton | |
| onClick={() => { | |
| field.handleChange( | |
| checked | |
| ? selectedAssetKeys.filter( | |
| ( | |
| value | |
| ) => | |
| value !== | |
| key | |
| ) | |
| : [ | |
| ...selectedAssetKeys, | |
| key, | |
| ] | |
| ) | |
| }} | |
| > | |
| <ListItemIcon> | |
| <Checkbox | |
| edge="start" | |
| checked={ | |
| checked | |
| } | |
| tabIndex={ | |
| -1 | |
| } | |
| disableRipple | |
| /> | |
| </ListItemIcon> | |
| <ListItemText | |
| primary={ | |
| asset.symbol | |
| } | |
| secondary={`${asset.displayName} · ${asset.id} · ${asset.admin}`} | |
| /> | |
| </ListItemButton> | |
| </ListItem> | |
| ) | |
| } | |
| )} | |
| </List> | |
| </Paper> | |
| ) : ( | |
| <Alert severity="info"> | |
| No assets found for this | |
| registry. | |
| </Alert> | |
| )} | |
| {field.state.meta.isTouched && | |
| field.state.meta.errors | |
| .length > 0 && ( | |
| <FormHelperText error> | |
| { | |
| field.state.meta | |
| .errors[0] | |
| ?.message | |
| } | |
| </FormHelperText> | |
| )} | |
| </Box> | |
| ) | |
| }} | |
| </form.Field> | |
| </> | |
| )} | |
| <form.Subscribe | |
| selector={(state) => ({ | |
| canSubmit: state.canSubmit, | |
| isSubmitting: state.isSubmitting, | |
| values: state.values, | |
| })} | |
| > | |
| {({ canSubmit, isSubmitting, values }) => { | |
| const createDisabled = | |
| !canSubmit || | |
| isSubmitting || | |
| !primaryParty || | |
| !sdk || | |
| isWalletSdkLoading || | |
| values.selectedAssetKeys.length === 0 | |
| return hasFetchedAssets ? ( | |
| <Button | |
| type="submit" | |
| variant="contained" | |
| disabled={createDisabled} | |
| sx={{ | |
| alignSelf: 'flex-start', | |
| minWidth: 160, | |
| }} | |
| > | |
| {isSubmitting | |
| ? 'Creating...' | |
| : 'Create'} | |
| </Button> | |
| ) : null | |
| }} | |
| </form.Subscribe> | |
| </Box> | |
| </Box> | |
| </CardContent> | |
| </Card> | |
| ) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment