Skip to content

Instantly share code, notes, and snippets.

@fayimora
Created May 21, 2026 21:36
Show Gist options
  • Select an option

  • Save fayimora/4c5cd1061932804fa5d7d6eb286e69ea to your computer and use it in GitHub Desktop.

Select an option

Save fayimora/4c5cd1061932804fa5d7d6eb286e69ea to your computer and use it in GitHub Desktop.
// Copyright (c) 2025-2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useMemo, useState } from 'react'
import CloseIcon from '@mui/icons-material/Close'
import {
Alert,
Box,
CircularProgress,
IconButton,
InputAdornment,
TextField,
Typography,
type SxProps,
type Theme,
} from '@mui/material'
import { DateTimePicker } from '@mui/x-date-pickers'
import { useForm } from '@tanstack/react-form'
import type { AnyFieldApi } from '@tanstack/react-form'
import Decimal from 'decimal.js'
import { z } from 'zod'
import { CopyIconButton } from '@components/ui/CopyIconButton'
import { PillButton } from '@components/ui/PillButton'
import { usePrimaryAccount } from '@hooks/useAccounts'
import { useCreateTransfer } from '@hooks/useCreateTransfer'
import { useInstrumentAvailableBalance } from '@hooks/useInstrumentAvailableBalance'
import { InstrumentSelect } from './instrument-select'
import type {
SelectableInstrument,
SubmittedTransfer,
TransferFormData,
} from './transfer-types'
interface TransferFormProps {
initialValues?: TransferFormData
onClose: () => void
onPendingChange: (pending: boolean) => void
onSubmitted: (transfer: SubmittedTransfer) => void
}
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
const DESCRIPTION_LIMIT = 280
const createDefaultTransferValues = (): TransferFormData => ({
instrumentId: null,
amount: '',
recipient: '',
memo: '',
expiry: new Date(Date.now() + SEVEN_DAYS_MS),
})
const instrumentValidator = z
.object({
admin: z.string(),
id: z.string(),
})
.nullable()
.refine((value) => value !== null, 'Please select an asset')
const recipientValidator = z
.string()
.trim()
.min(1, 'Recipient address is required')
const memoValidator = z
.string()
.max(DESCRIPTION_LIMIT, `${DESCRIPTION_LIMIT} characters maximum`)
const expiryValidator = z
.date()
.nullable()
.refine((value) => value !== null, 'Expiration date is required')
.refine(
(value) => value === null || !Number.isNaN(value.getTime()),
'Expiration date must be valid'
)
export const TransferForm: React.FC<TransferFormProps> = ({
initialValues,
onClose,
onPendingChange,
onSubmitted,
}) => {
const primaryAccount = usePrimaryAccount()
const primaryParty = primaryAccount?.partyId
const createTransferMutation = useCreateTransfer()
const [selectedInstrument, setSelectedInstrument] =
useState<SelectableInstrument | null>(null)
useEffect(() => {
onPendingChange(createTransferMutation.isPending)
}, [createTransferMutation.isPending, onPendingChange])
const defaultValues = useMemo(
() => initialValues ?? createDefaultTransferValues(),
[initialValues]
)
const form = useForm({
defaultValues,
onSubmit: async ({ value: formData }) => {
if (!primaryParty || !formData.instrumentId || !formData.expiry) {
return
}
const instrumentId = formData.instrumentId
const expiry = formData.expiry
createTransferMutation.mutate(
{
sender: primaryParty,
receiver: formData.recipient.trim(),
instrumentId,
amount: formData.amount,
expiry,
memo: formData.memo.trim() || undefined,
},
{
onSuccess: () => {
onSubmitted({
sender: primaryParty,
instrumentId,
instrumentSymbol: selectedInstrument?.symbol,
instrumentName: selectedInstrument?.name,
amount: formData.amount,
recipient: formData.recipient.trim(),
memo: formData.memo.trim(),
expiry,
submittedAt: new Date(),
})
},
}
)
},
})
const mutationError = createTransferMutation.error
const disabled = createTransferMutation.isPending
const handleInstrumentChange = (
instrument: SelectableInstrument | null
) => {
form.setFieldValue('instrumentId', instrument?.instrumentId ?? null)
}
const handleSelectedInstrumentChange = useCallback(
(instrument: SelectableInstrument | null) => {
setSelectedInstrument(instrument)
},
[]
)
return (
<Box
component="form"
onSubmit={(event) => {
event.preventDefault()
event.stopPropagation()
form.handleSubmit()
}}
sx={{ px: 3, pt: 3, pb: 3 }}
>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 3,
mb: 3,
}}
>
<Typography variant="h4" component="h2">
Make a transfer
</Typography>
<IconButton
aria-label="Close transfer dialog"
onClick={onClose}
disabled={disabled}
sx={{
color: 'secondary.main',
mt: 0.25,
'&:active': { transform: 'scale(0.97)' },
}}
>
<CloseIcon />
</IconButton>
</Box>
<Box sx={{ display: 'grid', gap: 3 }}>
{mutationError && (
<Alert severity="error">
Transfer failed:{' '}
{mutationError instanceof Error
? mutationError.message
: 'Unknown error'}
</Alert>
)}
<FieldBlock label="Wallet address">
{primaryParty && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.25,
color: 'text.secondary',
}}
>
<Typography
component="span"
sx={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 'inherit',
}}
>
{formatWalletAddress(primaryParty)}
</Typography>
<CopyIconButton
value={primaryParty}
sx={{
color: 'secondary.main',
p: 0.5,
'&:hover': { color: 'secondary.light' },
}}
/>
</Box>
)}
</FieldBlock>
<form.Field
name="recipient"
validators={{
onChange: recipientValidator,
onSubmit: recipientValidator,
}}
>
{(field) => (
<FieldBlock label="Recipient Address">
<TextField
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
onBlur={field.handleBlur}
disabled={disabled}
error={hasFieldError(field)}
helperText={getFieldError(field)}
fullWidth
sx={darkTextFieldSx}
/>
</FieldBlock>
)}
</form.Field>
<form.Field
name="instrumentId"
validators={{
onChange: instrumentValidator,
onSubmit: instrumentValidator,
}}
>
{(field) => (
<FieldBlock label="Select asset">
<InstrumentSelect
partyId={primaryParty}
value={field.state.value}
onChange={handleInstrumentChange}
onSelectedInstrumentChange={
handleSelectedInstrumentChange
}
disabled={disabled}
error={hasFieldError(field)}
helperText={getFieldError(field)}
/>
<Typography
variant="body2"
sx={{ mt: 0.75, color: 'text.secondary' }}
>
Available balance:{' '}
{selectedInstrument
? `${selectedInstrument.availableAmount} ${selectedInstrument.symbol}`
: '—'}
</Typography>
</FieldBlock>
)}
</form.Field>
<form.Subscribe selector={(state) => state.values.instrumentId}>
{(selectedInstrumentId) => (
<AmountField
form={form}
primaryParty={primaryParty}
selectedInstrumentId={selectedInstrumentId}
selectedInstrument={selectedInstrument}
disabled={disabled}
/>
)}
</form.Subscribe>
<form.Field
name="expiry"
validators={{
onChange: expiryValidator,
onSubmit: expiryValidator,
}}
>
{(field) => (
<FieldBlock label="Expiration date">
<DateTimePicker
value={field.state.value}
onChange={(date) => field.handleChange(date)}
disabled={disabled}
slotProps={{
textField: {
fullWidth: true,
error: hasFieldError(field),
helperText:
getFieldError(field) ??
'Date the transfer offer expires',
sx: darkTextFieldSx,
},
openPickerButton: {
sx: { color: 'text.primary' },
},
}}
/>
</FieldBlock>
)}
</form.Field>
<form.Field
name="memo"
validators={{
onChange: memoValidator,
onSubmit: memoValidator,
}}
>
{(field) => {
const charactersLeft = Math.max(
0,
DESCRIPTION_LIMIT - field.state.value.length
)
return (
<FieldBlock label="Description">
<TextField
value={field.state.value}
placeholder="Placeholder"
onChange={(event) =>
field.handleChange(event.target.value)
}
disabled={disabled}
error={hasFieldError(field)}
helperText={getFieldError(field)}
multiline
rows={4}
fullWidth
sx={darkTextFieldSx}
/>
<Typography
sx={{
mt: 1,
color: 'text.secondary',
}}
>
{charactersLeft} characters left
</Typography>
</FieldBlock>
)
}}
</form.Field>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
})}
>
{({ canSubmit, isSubmitting }) => (
<PillButton
type="submit"
fullWidth
disabled={
!canSubmit ||
isSubmitting ||
disabled ||
!primaryParty
}
sx={{ mt: 0.5, minHeight: 48, fontWeight: 700 }}
>
{disabled ? (
<CircularProgress size={24} color="inherit" />
) : (
'Make Transfer'
)}
</PillButton>
)}
</form.Subscribe>
</Box>
</Box>
)
}
interface FieldBlockProps {
label: string
children: React.ReactNode
}
const FieldBlock: React.FC<FieldBlockProps> = ({ label, children }) => (
<Box>
<Typography
sx={{
mb: 0.75,
color: 'text.primary',
fontWeight: 800,
textTransform: 'uppercase',
}}
>
{label}
</Typography>
{children}
</Box>
)
interface AmountFieldProps {
// TanStack Form's component API is difficult to name cleanly here; this
// mirrors the existing transfer dialog's pragmatic nested field typing.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any
primaryParty: string | undefined
selectedInstrumentId: TransferFormData['instrumentId']
selectedInstrument: SelectableInstrument | null
disabled: boolean
}
const AmountField: React.FC<AmountFieldProps> = ({
form,
primaryParty,
selectedInstrumentId,
selectedInstrument,
disabled,
}) => {
const availableHolding = useInstrumentAvailableBalance(
primaryParty,
selectedInstrumentId
)
const availableBalance = availableHolding?.availableAmount ?? '0'
const amountValidator = useMemo(
() =>
z
.string()
.min(1, 'Amount is required')
.refine((value) => {
try {
return new Decimal(value).isPositive()
} catch {
return false
}
}, 'Amount must be a positive number')
.refine((value) => {
if (!selectedInstrumentId) return true
try {
const requested = new Decimal(value || '0')
const available = new Decimal(availableBalance)
return requested.lte(available)
} catch {
return false
}
}, `Amount exceeds available balance of ${availableBalance}`),
[availableBalance, selectedInstrumentId]
)
return (
<form.Field
name="amount"
validators={{
onChange: amountValidator,
onSubmit: amountValidator,
}}
>
{(field: AnyFieldApi) => (
<FieldBlock label="Amount">
<TextField
type="number"
value={field.state.value}
onChange={(event) =>
field.handleChange(event.target.value)
}
onBlur={field.handleBlur}
disabled={disabled}
error={hasFieldError(field)}
helperText={getFieldError(field)}
slotProps={{
htmlInput: {
min: 0,
step: 'any',
},
input: {
endAdornment: selectedInstrument ? (
<InputAdornment position="end">
<Typography
sx={{ color: 'text.primary' }}
>
{selectedInstrument.symbol}
</Typography>
</InputAdornment>
) : undefined,
},
}}
fullWidth
sx={darkTextFieldSx}
/>
</FieldBlock>
)}
</form.Field>
)
}
const darkTextFieldSx: SxProps<Theme> = {
'& .MuiInputBase-root, & .MuiPickersInputBase-root': {
minHeight: 48,
bgcolor: (theme) => theme.portfolio.surface.required,
color: 'text.primary',
borderRadius: 1,
'& textarea': {
lineHeight: 1.35,
},
'& fieldset, & .MuiPickersOutlinedInput-notchedOutline': {
border: 'none',
},
'&:hover fieldset, &:hover .MuiPickersOutlinedInput-notchedOutline': {
border: 'none',
},
'&.Mui-focused fieldset, &.Mui-focused .MuiPickersOutlinedInput-notchedOutline':
(theme) => ({
border: `1px solid ${theme.palette.secondary.main}`,
}),
'&.Mui-disabled': {
bgcolor: 'action.disabledBackground',
color: 'text.disabled',
},
},
'& .MuiInputBase-input, & .MuiPickersInputBase-input': {
px: 2.5,
py: 1.25,
'&::placeholder': {
color: 'text.disabled',
opacity: 1,
},
},
'& .MuiFormHelperText-root': {
ml: 0,
mt: 1,
color: 'text.secondary',
fontSize: 14,
},
'& .MuiFormHelperText-root.Mui-error': {
color: 'error.main',
},
}
const hasFieldError = (field: AnyFieldApi) =>
field.state.meta.isTouched && field.state.meta.errors.length > 0
const getFieldError = (field: AnyFieldApi) => {
if (!hasFieldError(field)) return undefined
return formatFieldError(field.state.meta.errors[0])
}
const formatFieldError = (error: unknown): string | undefined => {
if (!error) return undefined
if (Array.isArray(error)) return formatFieldError(error[0])
if (error instanceof Error) return error.message
if (typeof error === 'object' && 'message' in error) {
return String(error.message)
}
return String(error)
}
const formatWalletAddress = (value: string) => {
if (value.length <= 44) return value
return `${value.slice(0, 16)}...${value.slice(-24)}`
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment