Skip to content

Commit c5ce771

Browse files
feat: add ADA handle bitcoin resolution (#1901)
1 parent 84841a4 commit c5ce771

File tree

7 files changed

+213
-20
lines changed

7 files changed

+213
-20
lines changed

apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/ReviewTransaction.module.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
word-break: break-all;
77
}
88

9+
.handle {
10+
word-break: break-all;
11+
font-weight: bold;
12+
}
13+
914
.divider {
1015
height: 1px;
1116
background-color: var(--light-mode-light-grey-plus, var(--dark-mode-mid-grey, #333333));

apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/ReviewTransaction.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const SATS_IN_BTC = 100_000_000;
1313
const MAXIMUM_FEE_DECIMAL = 8;
1414

1515
interface ReviewTransactionProps {
16-
unsignedTransaction: Bitcoin.UnsignedTransaction;
16+
unsignedTransaction: Bitcoin.UnsignedTransaction & { isHandle: boolean; handle: string };
1717
btcToUsdRate: number;
1818
feeRate: number;
1919
estimatedTime: string;
@@ -81,6 +81,11 @@ export const ReviewTransaction: React.FC<ReviewTransactionProps> = ({
8181
})}
8282
<Flex flexDirection="column">
8383
<Flex flexDirection="column" w="$fill" alignItems="flex-end" gap="$4">
84+
{unsignedTransaction.isHandle && (
85+
<Text.Address className={styles.handle} data-testid="output-summary-recipient-address-handle">
86+
{isPopupView ? addEllipsis(unsignedTransaction.handle, 12, 5) : unsignedTransaction.handle}
87+
</Text.Address>
88+
)}
8489
<Text.Address className={styles.address} data-testid="output-summary-recipient-address">
8590
{isPopupView ? addEllipsis(unsignedTransaction.toAddress, 12, 5) : unsignedTransaction.toAddress}
8691
</Text.Address>

apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendFlow.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/* eslint-disable unicorn/no-null */
66
/* eslint-disable max-statements */
77
/* eslint-disable consistent-return */
8+
/* eslint-disable func-call-spacing */
89
import React, { useCallback, useEffect, useMemo, useState } from 'react';
910
import { SendStepOne } from './SendStepOne';
1011
import { ReviewTransaction } from './ReviewTransaction';
@@ -25,6 +26,7 @@ import { useTranslation } from 'react-i18next';
2526
import { WarningModal } from '@views/browser/components';
2627
import styles from './SendFlow.module.scss';
2728
import { TxCreationType, TX_CREATION_TYPE_KEY } from '@providers/AnalyticsProvider/analyticsTracker';
29+
import { AddressValue } from './types';
2830

2931
const SATS_IN_BTC = 100_000_000;
3032

@@ -113,9 +115,11 @@ export const SendFlow: React.FC = () => {
113115
const [step, setStep] = useState<Step>('AMOUNT');
114116

115117
const [amount, setAmount] = useState<string>('');
116-
const [address, setAddress] = useState<string>('');
118+
const [address, setAddress] = useState<AddressValue | undefined>();
117119

118-
const [unsignedTransaction, setUnsignedTransaction] = useState<Bitcoin.UnsignedTransaction | null>(null);
120+
const [unsignedTransaction, setUnsignedTransaction] = useState<
121+
(Bitcoin.UnsignedTransaction & { isHandle: boolean; handle: string }) | null
122+
>(null);
119123
const [feeRate, setFeeRate] = useState<number>(1);
120124
const [estimatedTime, setEstimatedTime] = useState<string>('~30 min');
121125

@@ -256,17 +260,19 @@ export const SendFlow: React.FC = () => {
256260
const goToReview = (newFeeRate: number) => {
257261
analytics.sendEventToPostHog(PostHogAction.SendTransactionDataReviewTransactionClick);
258262
setFeeRate(newFeeRate);
259-
setUnsignedTransaction(
260-
buildTransaction({
263+
setUnsignedTransaction({
264+
...buildTransaction({
261265
knownAddresses,
262266
changeAddress: knownAddresses[0].address,
263-
recipientAddress: address,
267+
recipientAddress: address.isHandle ? address.resolvedAddress : address.address,
264268
feeRate: newFeeRate,
265269
amount: btcStringToSatoshisBigint(amount),
266270
utxos,
267271
network
268-
})
269-
);
272+
}),
273+
isHandle: address.isHandle,
274+
handle: address.isHandle ? address.address : ''
275+
});
270276
setStep('REVIEW');
271277
setDrawerConfig({
272278
...config,

apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.module.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,17 @@
1919
-moz-appearance: textfield;
2020
}
2121
}
22+
23+
:global(.anticon) {
24+
&.valid {
25+
color: var(--text-color-green);
26+
}
27+
28+
&.invalid {
29+
color: var(--text-color-red);
30+
}
31+
32+
&.loading {
33+
color: var(--text-color-grey);
34+
}
35+
}

apps/browser-extension-wallet/src/views/bitcoin-mode/features/send/components/SendStepOne.tsx

Lines changed: 157 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@
22
/* eslint-disable react/no-multi-comp */
33
/* eslint-disable complexity */
44
/* eslint-disable no-magic-numbers */
5+
/* eslint-disable max-statements */
56
import React, { useCallback, useEffect, useMemo, useState } from 'react';
67
import { Button, Input, Search } from '@lace/common';
78
import styles from './SendStepOne.module.scss';
89
import mainStyles from './SendFlow.module.scss';
9-
import { AssetInput } from '@lace/core';
10+
import { AssetInput, HANDLE_DEBOUNCE_TIME, isHandle } from '@lace/core';
1011
import BigNumber from 'bignumber.js';
11-
import { useFetchCoinPrice } from '@hooks';
12+
import { useFetchCoinPrice, useHandleResolver } from '@hooks';
1213
import { CoreTranslationKey } from '@lace/translation';
1314
import { Box, Flex, Text, ToggleButtonGroup } from '@input-output-hk/lace-ui-toolkit';
1415
import { Bitcoin } from '@lace/bitcoin';
1516
import { useTranslation } from 'react-i18next';
1617
import { formatNumberForDisplay } from '@utils/format-number';
18+
import { isAdaHandleEnabled } from '@src/features/ada-handle/config';
19+
import { Asset } from '@cardano-sdk/core';
20+
import debounce from 'lodash/debounce';
21+
import { CustomConflictError, CustomError, ensureHandleOwnerHasntChanged, verifyHandle } from '@utils/validators';
22+
import { AddressValue, HandleVerificationState } from './types';
23+
import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons';
1724

1825
const SATS_IN_BTC = 100_000_000;
1926

@@ -34,9 +41,9 @@ const fees: RecommendedFee[] = [
3441
interface SendStepOneProps {
3542
amount: string;
3643
onAmountChange: (value: string) => void;
37-
address: string;
44+
address: AddressValue;
3845
availableBalance: number;
39-
onAddressChange: (value: string) => void;
46+
onAddressChange: (value: AddressValue) => void;
4047
feeMarkets: Bitcoin.EstimatedFees | null;
4148
onEstimatedTimeChange: (value: string) => void;
4249
onContinue: (feeRate: number) => void;
@@ -64,6 +71,10 @@ const InputError = ({
6471
</Box>
6572
);
6673

74+
type HandleIcons = {
75+
[key in HandleVerificationState]: JSX.Element | undefined;
76+
};
77+
6778
export const SendStepOne: React.FC<SendStepOneProps> = ({
6879
amount,
6980
onAmountChange,
@@ -83,6 +94,7 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
8394
const hasNoValue = numericAmount === 0;
8495
const exceedsBalance = numericAmount > availableBalance / SATS_IN_BTC;
8596
const [feeRate, setFeeRate] = useState<number>(1);
97+
const handleResolver = useHandleResolver();
8698

8799
const getFees = useCallback(
88100
() =>
@@ -93,6 +105,7 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
93105
[feeMarkets]
94106
);
95107

108+
const [handleVerificationState, setHandleVerificationState] = useState<HandleVerificationState | undefined>();
96109
const [recommendedFees, setRecommendedFees] = useState<RecommendedFee[]>(getFees());
97110
const [selectedFeeKey, setSelectedFeeKey] = useState<RecommendedFee['key']>(
98111
recommendedFees.find((f) => f.feeRate === feeRate)?.key || recommendedFees[1]?.key
@@ -101,10 +114,28 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
101114
() => recommendedFees.find((f) => f.key === selectedFeeKey),
102115
[recommendedFees, selectedFeeKey]
103116
);
117+
104118
const [customFee, setCustomFee] = useState<string>('0');
105119
const [customFeeError, setCustomFeeError] = useState<string | undefined>();
106120
const [isValidAddress, setIsValidAddress] = useState<boolean>(false);
107121
const [invalidAddressError, setInvalidAddressError] = useState<string | undefined>();
122+
const [icon, setIcon] = useState<JSX.Element | undefined>();
123+
124+
const calcIcon = debounce((state: HandleVerificationState | undefined) => {
125+
const handleIcons: HandleIcons = {
126+
[HandleVerificationState.VALID]: <CheckCircleOutlined className={styles.valid} />,
127+
[HandleVerificationState.CHANGED_OWNERSHIP]: <CheckCircleOutlined className={styles.valid} />,
128+
[HandleVerificationState.INVALID]: <ExclamationCircleOutlined className={styles.invalid} />,
129+
[HandleVerificationState.VERIFYING]: <LoadingOutlined className={styles.loading} />
130+
};
131+
const handleIcon = (state && handleIcons[state]) || undefined;
132+
setIcon(handleIcon);
133+
}, HANDLE_DEBOUNCE_TIME);
134+
135+
useEffect(() => {
136+
calcIcon(handleVerificationState);
137+
return () => calcIcon.cancel();
138+
}, [handleVerificationState, calcIcon]);
108139

109140
useEffect(() => {
110141
// eslint-disable-next-line unicorn/no-useless-undefined
@@ -125,7 +156,12 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
125156
}, [getFees]);
126157

127158
const handleNext = () => {
128-
if (hasNoValue || exceedsBalance || address.trim() === '') return;
159+
if (
160+
hasNoValue || exceedsBalance || address?.isHandle
161+
? address?.resolvedAddress.trim() === ''
162+
: address?.address.trim() === ''
163+
)
164+
return;
129165

130166
const newFeeRate =
131167
selectedFeeKey === 'custom'
@@ -149,8 +185,102 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
149185

150186
const fiatValue = `≈ ${new BigNumber(enteredAmount.toString()).toFixed(2, BigNumber.ROUND_HALF_UP)} USD`;
151187

188+
const isAddressInputInvalidHandle =
189+
isAdaHandleEnabled &&
190+
isHandle(address?.address) &&
191+
!Asset.util.isValidHandle(address?.address?.toString().slice(1).toLowerCase());
192+
193+
const isAddressInputValueHandle = isAdaHandleEnabled && isHandle(address?.address);
194+
195+
const resolveHandle = useMemo(
196+
() =>
197+
debounce(async () => {
198+
setHandleVerificationState(HandleVerificationState.VERIFYING);
199+
200+
if (isAddressInputInvalidHandle) {
201+
setHandleVerificationState(HandleVerificationState.INVALID);
202+
}
203+
if (!address?.handleResolution) {
204+
const { valid, handles } = await verifyHandle(address?.address, handleResolver);
205+
206+
if (valid && !!handles[0].addresses?.bitcoin) {
207+
setHandleVerificationState(HandleVerificationState.VALID);
208+
209+
onAddressChange({
210+
address: address?.address,
211+
resolvedAddress: handles[0].addresses?.bitcoin,
212+
isHandle: true,
213+
handleResolution: handles[0]
214+
});
215+
} else {
216+
setHandleVerificationState(HandleVerificationState.INVALID);
217+
}
218+
return;
219+
}
220+
221+
try {
222+
await ensureHandleOwnerHasntChanged({
223+
force: true,
224+
handleResolution: address?.handleResolution,
225+
handleResolver
226+
});
227+
228+
setHandleVerificationState(HandleVerificationState.VALID);
229+
} catch (error) {
230+
if (error instanceof CustomError && error.isValidHandle === false) {
231+
setHandleVerificationState(HandleVerificationState.INVALID);
232+
}
233+
if (error instanceof CustomConflictError) {
234+
setHandleVerificationState(HandleVerificationState.CHANGED_OWNERSHIP);
235+
}
236+
}
237+
}, HANDLE_DEBOUNCE_TIME),
238+
[address?.address, address?.handleResolution, onAddressChange, handleResolver, isAddressInputInvalidHandle]
239+
);
240+
241+
useEffect(() => {
242+
if (!address) {
243+
return;
244+
}
245+
246+
if (isAddressInputValueHandle) {
247+
resolveHandle();
248+
} else {
249+
// eslint-disable-next-line unicorn/no-useless-undefined
250+
setHandleVerificationState(undefined);
251+
}
252+
253+
// eslint-disable-next-line consistent-return
254+
return () => {
255+
resolveHandle && resolveHandle.cancel();
256+
};
257+
}, [address, setHandleVerificationState, resolveHandle, isAddressInputValueHandle]);
258+
152259
useEffect(() => {
153-
const result = Bitcoin.validateBitcoinAddress(address, network);
260+
if (handleVerificationState === HandleVerificationState.VERIFYING) return;
261+
262+
if (isAddressInputValueHandle) {
263+
if (
264+
!address?.isHandle ||
265+
isAddressInputInvalidHandle ||
266+
handleVerificationState === HandleVerificationState.INVALID
267+
) {
268+
setIsValidAddress(false);
269+
setInvalidAddressError(t('core.destinationAddressInput.invalidBitcoinHandle'));
270+
return;
271+
}
272+
273+
if (handleVerificationState === HandleVerificationState.CHANGED_OWNERSHIP) {
274+
setIsValidAddress(false);
275+
setInvalidAddressError(t('core.destinationAddressInput.handleChangedOwner'));
276+
return;
277+
}
278+
}
279+
280+
const addressToValidate = address?.isHandle ? address?.resolvedAddress : address?.address;
281+
282+
if (!addressToValidate) return;
283+
const result = Bitcoin.validateBitcoinAddress(addressToValidate, network);
154284

155285
switch (result) {
156286
case Bitcoin.AddressValidationResult.Valid:
@@ -167,7 +297,15 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
167297
setInvalidAddressError(t('general.errors.incorrectAddress'));
168298
break;
169299
}
170-
}, [address, network, onAddressChange, t]);
300+
}, [
301+
isAddressInputValueHandle,
302+
isAddressInputInvalidHandle,
303+
handleVerificationState,
304+
address,
305+
network,
306+
onAddressChange,
307+
t
308+
]);
171309

172310
const handleCustomFeeKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
173311
const disallowedKeys = ['-', '+', 'e', ','];
@@ -196,14 +334,22 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
196334

197335
<Search
198336
disabled={hasUtxosInMempool}
199-
value={address}
337+
value={address?.address}
200338
data-testid="btc-address-input"
201339
label={t('core.destinationAddressInput.recipientAddressOnly')}
202-
onChange={(value) => onAddressChange(value)}
340+
onChange={(value) => {
341+
setHandleVerificationState(HandleVerificationState.VERIFYING);
342+
// eslint-disable-next-line unicorn/no-useless-undefined
343+
setInvalidAddressError(undefined);
344+
onAddressChange({ isHandle: false, address: value, resolvedAddress: '' });
345+
}}
203346
style={{ width: '100%' }}
347+
customIcon={icon}
204348
/>
205349

206-
{!isValidAddress && !!address?.length && <InputError error={invalidAddressError} isPopupView={isPopupView} />}
350+
{!isValidAddress && !!address?.address?.length && (
351+
<InputError error={invalidAddressError} isPopupView={isPopupView} />
352+
)}
207353

208354
<Box w="$fill" mt={isPopupView ? '$16' : '$40'} py="$24" px="$32" className={styles.amountSection}>
209355
<AssetInput
@@ -299,7 +445,7 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
299445
className={mainStyles.buttons}
300446
>
301447
<Button
302-
disabled={hasNoValue || exceedsBalance || address.trim() === '' || !isValidAddress}
448+
disabled={hasNoValue || exceedsBalance || !address || address?.address?.trim() === '' || !isValidAddress}
303449
color="primary"
304450
block
305451
size="medium"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { HandleResolution } from '@cardano-sdk/core';
2+
3+
export type AddressValue = {
4+
isHandle: boolean;
5+
address: string;
6+
resolvedAddress: string;
7+
handleResolution?: HandleResolution;
8+
};
9+
10+
export enum HandleVerificationState {
11+
VALID = 'valid',
12+
INVALID = 'invalid',
13+
VERIFYING = 'verifying',
14+
CHANGED_OWNERSHIP = 'changedOwnership'
15+
}

packages/translation/src/lib/translations/core/en.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@
272272
"core.dappTransaction.transactionSummary": "Transaction Summary",
273273
"core.dappTransaction.transactionSummaryTooltip": "This summary includes all assets entering or leaving your wallet as part of this transaction, including fees, deposits.",
274274
"core.dappTransaction.tryingToUseAssetNotInWallet": "This DApp is trying to use token not held in your wallet.",
275-
"core.destinationAddressInput.recipientAddressOnly": "Recipient's address",
275+
"core.destinationAddressInput.recipientAddressOnly": "Recipient's address or $handle",
276+
"core.destinationAddressInput.invalidBitcoinHandle": "Can't resolve Bitcoin address from $handle",
277+
"core.destinationAddressInput.handleChangedOwner": "$handle changes owner",
276278
"core.destinationAddressInput.recipientAddress": "Recipient's address or $handle",
277279
"core.DRepRegistration.depositPaid": "Deposit paid",
278280
"core.DRepRegistration.drepId": "DRep ID",

0 commit comments

Comments
 (0)