Skip to content

Commit bb8d8ce

Browse files
feat: add op return message to bitcoin (#1946)
1 parent c6cb918 commit bb8d8ce

File tree

14 files changed

+282
-29
lines changed

14 files changed

+282
-29
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
@import '../../../../../../../../packages/common/src/ui/styles/theme.scss';
2+
@import '../../../../../../../../packages/common/src/ui/styles/abstracts/typography';
3+
4+
.opReturnMessageInput {
5+
margin-top: size_unit(2);
6+
padding-bottom: size_unit(3);
7+
width: 100%;
8+
9+
:global(.ant-input-affix-wrapper) {
10+
transition: none !important;
11+
}
12+
13+
&.visibleCounter {
14+
padding-bottom: size_unit(2.5);
15+
16+
:global(.ant-input-suffix) {
17+
position: absolute;
18+
right: size_unit(0.5);
19+
top: size_unit(0.5);
20+
width: size_unit(6.5);
21+
}
22+
}
23+
24+
input {
25+
@include text-bodyLarge-medium;
26+
color: var(--text-color-primary) !important;
27+
transition: none;
28+
::placeholder {
29+
color: var(--text-color-secondary) !important;
30+
}
31+
}
32+
33+
@media (max-width: 400px) {
34+
margin-top: size_unit(1);
35+
padding-bottom: size_unit(5);
36+
37+
&.visibleCounter {
38+
padding-bottom: size_unit(4);
39+
}
40+
}
41+
42+
.suffixContent {
43+
margin-right: -7px;
44+
&.focus {
45+
margin-bottom: -#{size_unit(1.5)};
46+
}
47+
}
48+
}
49+
50+
.characterCounter {
51+
margin: size_unit(0.5) 0 0;
52+
padding-left: size_unit(3);
53+
@include text-form-label($weight: 500);
54+
color: var(--text-color-secondary);
55+
56+
&.error {
57+
color: var(--data-pink);
58+
}
59+
}
60+
61+
.iconSize {
62+
font-size: size_unit(3) !important;
63+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/* eslint-disable unicorn/no-useless-undefined */
2+
import React, { useState } from 'react';
3+
import { Input, TextBoxItem } from '@lace/common';
4+
import classnames from 'classnames';
5+
import styles from './OpReturnMessageInput.module.scss';
6+
import { useTranslation, Trans } from 'react-i18next';
7+
8+
const MAX_LENGTH = 80;
9+
10+
interface OpReturnInputProps {
11+
onOpReturnMessageChange: (value: string) => void;
12+
opReturnMessage: string;
13+
disabled?: boolean;
14+
}
15+
16+
export const OpReturnMessageInput: React.FC<OpReturnInputProps> = ({
17+
onOpReturnMessageChange,
18+
opReturnMessage,
19+
disabled = false
20+
}) => {
21+
const { t } = useTranslation();
22+
const [value, setOpReturnMessageMsg] = useState(opReturnMessage);
23+
const [focused, setFocused] = useState(false);
24+
const handleChange = ({ target }: React.ChangeEvent<HTMLInputElement>) => {
25+
setOpReturnMessageMsg(target.value);
26+
onOpReturnMessageChange(target.value);
27+
};
28+
29+
const handleClick = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
30+
event.stopPropagation();
31+
setOpReturnMessageMsg(undefined);
32+
onOpReturnMessageChange('');
33+
};
34+
35+
const handleFocusedState = () => setFocused((prev) => !prev);
36+
37+
const hasReachedCharLimit = value?.length > MAX_LENGTH;
38+
const displayCounter = !!value || focused;
39+
return (
40+
<div
41+
data-testid="opReturn-message-input-container"
42+
className={classnames(styles.opReturnMessageInput, { [styles.visibleCounter]: displayCounter })}
43+
>
44+
<Input
45+
onFocus={handleFocusedState}
46+
onBlur={handleFocusedState}
47+
data-testid="opReturn-message-input"
48+
value={value}
49+
onChange={handleChange}
50+
suffix={
51+
<div
52+
data-testid="opReturn-message-input-suffix"
53+
className={classnames(styles.suffixContent, { [styles.focus]: focused || value?.length > 0 })}
54+
>
55+
<TextBoxItem iconClassName={styles.iconSize} onClick={handleClick} disabled={!value} />
56+
</div>
57+
}
58+
label={t('browserView.transaction.send.metadata.addAOpReturnNote')}
59+
focus={focused}
60+
disabled={disabled}
61+
/>
62+
63+
{displayCounter && (
64+
<p
65+
data-testid="opReturn-message-counter"
66+
className={classnames(styles.characterCounter, {
67+
[styles.error]: hasReachedCharLimit
68+
})}
69+
>
70+
<Trans
71+
values={{ count: `${value?.length || 0}/${MAX_LENGTH}` }}
72+
i18nKey="browserView.transaction.send.metadata.count"
73+
/>
74+
</p>
75+
)}
76+
</div>
77+
);
78+
};

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

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* eslint-disable complexity */
1+
/* eslint-disable complexity, sonarjs/cognitive-complexity */
22
/* eslint-disable no-magic-numbers */
33
import React from 'react';
44
import { renderLabel, RowContainer } from '@lace/core';
@@ -16,6 +16,7 @@ interface ReviewTransactionProps {
1616
unsignedTransaction: Bitcoin.UnsignedTransaction & { isHandle: boolean; handle: string };
1717
btcToUsdRate: number;
1818
feeRate: number;
19+
opReturnMessage: string;
1920
estimatedTime: string;
2021
onConfirm: () => void;
2122
onClose: () => void;
@@ -29,12 +30,14 @@ export const ReviewTransaction: React.FC<ReviewTransactionProps> = ({
2930
estimatedTime,
3031
onConfirm,
3132
isPopupView,
32-
onClose
33+
onClose,
34+
opReturnMessage
3335
}) => {
3436
const { t } = useTranslation();
3537
const amount = Number(unsignedTransaction.amount);
3638
const usdValue = (amount / SATS_IN_BTC) * btcToUsdRate;
3739
const feeInBtc = unsignedTransaction.fee;
40+
const hasOpReturn = opReturnMessage && opReturnMessage.length > 0;
3841

3942
return (
4043
<Flex flexDirection="column" w="$fill" className={mainStyles.container}>
@@ -94,6 +97,24 @@ export const ReviewTransaction: React.FC<ReviewTransactionProps> = ({
9497
</RowContainer>
9598
</Box>
9699

100+
{hasOpReturn && (
101+
<Box w="$fill" mt={isPopupView ? '$24' : '$32'}>
102+
<RowContainer>
103+
{renderLabel({
104+
label: t('core.outputSummaryList.note'),
105+
dataTestId: 'output-summary-note'
106+
})}
107+
<Flex flexDirection="column">
108+
<Flex flexDirection="column" w="$fill" alignItems="flex-end" gap="$4">
109+
<Text.Address className={styles.address} data-testid="output-summary-note">
110+
{opReturnMessage}
111+
</Text.Address>
112+
</Flex>
113+
</Flex>
114+
</RowContainer>
115+
</Box>
116+
)}
117+
97118
<Box w="$fill" mt={isPopupView ? '$32' : '$40'} mb="$8" className={styles.divider} />
98119

99120
<Box w="$fill" mt={isPopupView ? '$24' : '$32'}>

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ type BuildTxProps = {
8080
knownAddresses: Bitcoin.DerivedAddress[];
8181
changeAddress: string;
8282
recipientAddress: string;
83+
opReturnMessage: string;
8384
feeRate: number;
8485
amount: bigint;
8586
utxos: Bitcoin.UTxO[];
@@ -93,12 +94,14 @@ const buildTransaction = ({
9394
feeRate,
9495
amount,
9596
utxos,
97+
opReturnMessage,
9698
network
9799
}: BuildTxProps): Bitcoin.UnsignedTransaction =>
98100
new Bitcoin.TransactionBuilder(network, feeRate, knownAddresses)
99101
.setChangeAddress(changeAddress)
100102
.setUtxoSet(utxos)
101103
.addOutput(recipientAddress, amount)
104+
.addOpReturnOutput(opReturnMessage)
102105
.build();
103106

104107
const btcStringToSatoshisBigint = (btcString: string): bigint => {
@@ -132,7 +135,7 @@ export const SendFlow: React.FC = () => {
132135
const [confirmationHash, setConfirmationHash] = useState<string>('');
133136
const [txError, setTxError] = useState<Error | undefined>();
134137
const [isWarningModalVisible, setIsWarningModalVisible] = useState(false);
135-
138+
const [opReturnMessage, setOpReturnMessage] = useState<string>('');
136139
const { priceResult } = useFetchCoinPrice();
137140
const { bitcoinWallet } = useWalletManager();
138141

@@ -268,7 +271,8 @@ export const SendFlow: React.FC = () => {
268271
feeRate: newFeeRate,
269272
amount: btcStringToSatoshisBigint(amount),
270273
utxos,
271-
network
274+
network,
275+
opReturnMessage
272276
}),
273277
isHandle: address.isHandle,
274278
handle: address.isHandle ? address.address : ''
@@ -333,6 +337,8 @@ export const SendFlow: React.FC = () => {
333337
onContinue={goToReview}
334338
network={network}
335339
hasUtxosInMempool={hasUtxosInMempool}
340+
onOpReturnMessageChange={setOpReturnMessage}
341+
opReturnMessage={opReturnMessage}
336342
/>
337343
);
338344
}
@@ -348,6 +354,7 @@ export const SendFlow: React.FC = () => {
348354
feeRate={feeRate}
349355
estimatedTime={estimatedTime}
350356
onConfirm={goToPassword}
357+
opReturnMessage={opReturnMessage}
351358
/>
352359
</>
353360
);

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import debounce from 'lodash/debounce';
2121
import { CustomConflictError, CustomError, ensureHandleOwnerHasntChanged, verifyHandle } from '@utils/validators';
2222
import { AddressValue, HandleVerificationState } from './types';
2323
import { CheckCircleOutlined, ExclamationCircleOutlined, LoadingOutlined } from '@ant-design/icons';
24+
import { OpReturnMessageInput } from './OpReturnMessageInput';
2425

2526
const SATS_IN_BTC = 100_000_000;
2627

@@ -51,6 +52,8 @@ interface SendStepOneProps {
5152
onClose: () => void;
5253
network: Bitcoin.Network | null;
5354
hasUtxosInMempool: boolean;
55+
onOpReturnMessageChange: (value: string) => void;
56+
opReturnMessage: string;
5457
}
5558

5659
const InputError = ({
@@ -87,7 +90,9 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
8790
isPopupView,
8891
onClose,
8992
network,
90-
hasUtxosInMempool
93+
hasUtxosInMempool,
94+
onOpReturnMessageChange,
95+
opReturnMessage
9196
}) => {
9297
const { t } = useTranslation();
9398
const numericAmount = Number.parseFloat(amount) || 0;
@@ -369,6 +374,12 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
369374
/>
370375
</Box>
371376

377+
<OpReturnMessageInput
378+
opReturnMessage={opReturnMessage}
379+
disabled={hasUtxosInMempool}
380+
onOpReturnMessageChange={onOpReturnMessageChange}
381+
/>
382+
372383
<Box mt={isPopupView ? '$24' : '$32'}>
373384
{isPopupView ? (
374385
<Text.Body.Large weight="$bold">{t('browserView.transaction.btc.send.feeRate')}</Text.Body.Large>
@@ -445,7 +456,14 @@ export const SendStepOne: React.FC<SendStepOneProps> = ({
445456
className={mainStyles.buttons}
446457
>
447458
<Button
448-
disabled={hasNoValue || exceedsBalance || !address || address?.address?.trim() === '' || !isValidAddress}
459+
disabled={
460+
hasNoValue ||
461+
exceedsBalance ||
462+
!address ||
463+
address?.address?.trim() === '' ||
464+
!isValidAddress ||
465+
opReturnMessage?.length > 80
466+
}
449467
color="primary"
450468
block
451469
size="medium"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './SendContainer';
22
export * from './BitcoinSendDrawer';
3+
export * from './OpReturnMessageInput';

packages/bitcoin/src/wallet/lib/input-selection/GreedyInputSelector.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ export class GreedyInputSelector implements InputSelector {
1313
changeAddress: string,
1414
utxos: UTxO[],
1515
outputs: { address: string; value: bigint }[],
16-
feeRate: number
16+
feeRate: number,
17+
hasOpReturn: boolean
1718
): { selectedUTxOs: UTxO[]; outputs: { address: string; value: number }[]; fee: number } | undefined {
1819
const selected: UTxO[] = [];
1920
const totalOutput = outputs.reduce((acc, o) => acc + o.value, ZERO);
@@ -23,7 +24,7 @@ export class GreedyInputSelector implements InputSelector {
2324
for (const utxo of utxos) {
2425
selected.push(utxo);
2526
inputSum += utxo.satoshis;
26-
fee = this.computeFee(selected.length, outputs.length + 1, feeRate);
27+
fee = this.computeFee(selected.length, outputs.length + 1, feeRate, hasOpReturn);
2728
if (inputSum >= totalOutput + BigInt(fee)) break;
2829
}
2930

@@ -39,7 +40,8 @@ export class GreedyInputSelector implements InputSelector {
3940
remaining: utxos.slice(selected.length),
4041
inputSum,
4142
totalOutput,
42-
outputsCount: outputs.length
43+
outputsCount: outputs.length,
44+
hasOpReturn
4345
});
4446
change = newChange;
4547
fee += feeDelta;
@@ -60,8 +62,9 @@ export class GreedyInputSelector implements InputSelector {
6062
}
6163

6264
/** Estimate the fee for a given input / output count */
63-
private computeFee(inputCount: number, outputCount: number, feeRate: number): number {
64-
const size = inputCount * INPUT_SIZE + outputCount * OUTPUT_SIZE + TRANSACTION_OVERHEAD;
65+
private computeFee(inputCount: number, outputCount: number, feeRate: number, hasOpReturn: boolean): number {
66+
const size =
67+
inputCount * INPUT_SIZE + (outputCount + (hasOpReturn ? 1 : 0)) * OUTPUT_SIZE + Number(TRANSACTION_OVERHEAD);
6568
return Math.ceil(size * feeRate);
6669
}
6770

@@ -76,7 +79,8 @@ export class GreedyInputSelector implements InputSelector {
7679
remaining,
7780
inputSum,
7881
totalOutput,
79-
outputsCount
82+
outputsCount,
83+
hasOpReturn
8084
}: {
8185
change: bigint;
8286
feeRate: number;
@@ -85,6 +89,7 @@ export class GreedyInputSelector implements InputSelector {
8589
inputSum: bigint;
8690
totalOutput: bigint;
8791
outputsCount: number;
92+
hasOpReturn: boolean;
8893
}): { change: bigint; fee: number } {
8994
if (change === ZERO || Number(change) >= DUST_THRESHOLD) return { change, fee: 0 };
9095

@@ -93,15 +98,15 @@ export class GreedyInputSelector implements InputSelector {
9398

9499
for (const utxo of remaining) {
95100
const newInputSum = inputSum + utxo.satoshis;
96-
const newFee = this.computeFee(selected.length + 1, outputsCount + 1, feeRate);
101+
const newFee = this.computeFee(selected.length + 1, outputsCount + 1, feeRate, hasOpReturn);
97102
const newChange = newInputSum - totalOutput - BigInt(newFee);
98103

99104
const rescued = newChange - originalChange;
100105
const extraCost = BigInt(Math.ceil(INPUT_SIZE * feeRate));
101106

102107
if (rescued >= extraCost && newChange >= BigInt(DUST_THRESHOLD)) {
103108
selected.push(utxo);
104-
feeDelta = newFee - this.computeFee(selected.length - 1, outputsCount + 1, feeRate);
109+
feeDelta = newFee - this.computeFee(selected.length - 1, outputsCount + 1, feeRate, hasOpReturn);
105110
return { change: newChange, fee: feeDelta };
106111
}
107112
}

0 commit comments

Comments
 (0)