Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: public transaction notes #4693

Merged
merged 20 commits into from
Jan 8, 2025
Merged
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
5 changes: 5 additions & 0 deletions apps/web/src/components/transactions/TxDetails/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { FEATURES } from '@/utils/chains'
import { useGetTransactionDetailsQuery } from '@/store/api/gateway'
import { asError } from '@/services/exceptions/utils'
import { POLLING_INTERVAL } from '@/config/constants'
import { TxNote } from '@/features/tx-notes'

export const NOT_AVAILABLE = 'n/a'

Expand Down Expand Up @@ -82,6 +83,10 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement
<>
{/* /Details */}
<div className={`${css.details} ${isUnsigned ? css.noSigners : ''}`}>
<div className={css.txNote}>
<TxNote txDetails={txDetails} />
</div>

<div className={css.shareLink}>
<TxShareLink id={txSummary.id} />
</div>
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/components/transactions/TxDetails/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,20 @@
}

.shareLink {
position: absolute;
right: 16px;
top: 16px;
display: flex;
justify-content: flex-end;
margin: var(--space-1);
margin-bottom: -40px;
}

.txNote {
margin: var(--space-1) 0;
padding: 0 var(--space-2) var(--space-2);
border-bottom: 1px solid var(--color-border-light);
}

.txNote:empty {
display: none;
}

.loading,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Stack from '@mui/system/Stack'

export const ProposerForm = ({
safeTx,
origin,
disableSubmit = false,
txActions,
txSecurity,
Expand Down Expand Up @@ -51,7 +52,7 @@ export const ProposerForm = ({
setIsRejectedByUser(false)

try {
const txId = await signProposerTx(safeTx)
const txId = await signProposerTx(safeTx, origin)
onSubmit?.(txId)
} catch (_err) {
const err = asError(_err)
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/components/tx/SignOrExecuteForm/SignForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const SignForm = ({
onSubmit?.(resultTxId)
}

if (signer?.isSafe) {
if (!isAddingToBatch && signer?.isSafe) {
setTxFlow(<NestedTxSuccessScreenFlow txId={resultTxId} />, undefined, false)
} else {
setTxFlow(undefined)
Expand All @@ -101,8 +101,6 @@ export const SignForm = ({
const submitDisabled =
!safeTx || !isSubmittable || disableSubmit || cannotPropose || (needsRiskConfirmation && !isRiskConfirmed)

const isSafeAppTransaction = !!origin

return (
<form onSubmit={handleSubmit}>
{hasSigned && <ErrorMessage level="warning">You have already signed this transaction.</ErrorMessage>}
Expand Down Expand Up @@ -135,7 +133,7 @@ export const SignForm = ({
{isCreation && !isBatch && (
<BatchButton
onClick={onBatchClick}
disabled={submitDisabled || !isBatchable || isSafeAppTransaction}
disabled={submitDisabled || !isBatchable}
tooltip={!isBatchable ? `Cannot batch this type of transaction` : undefined}
/>
)}
Expand Down
78 changes: 50 additions & 28 deletions apps/web/src/components/tx/SignOrExecuteForm/SignOrExecuteForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import ConfirmationView from '../confirmation-views'
import { SignerForm } from './SignerForm'
import { useSigner } from '@/hooks/wallets/useWallet'
import { trackTxEvents } from './tracking'
import { TxNoteForm, encodeTxNote } from '@/features/tx-notes'

export type SubmitCallback = (txId: string, isExecuted?: boolean) => void

Expand Down Expand Up @@ -64,6 +65,7 @@ export const SignOrExecuteForm = ({
isCreation?: boolean
txDetails?: TransactionDetails
}): ReactElement => {
const [customOrigin, setCustomOrigin] = useState<string | undefined>(props.origin)
const { transactionExecution } = useAppSelector(selectSettings)
const [shouldExecute, setShouldExecute] = useState<boolean>(transactionExecution)
const isNewExecutableTx = useImmediatelyExecutable() && isCreation
Expand Down Expand Up @@ -108,10 +110,10 @@ export const SignOrExecuteForm = ({
isRoleExecution,
isProposerCreation,
!!signer?.isSafe,
props.origin,
customOrigin,
)
},
[chainId, isCreation, onSubmit, trigger, signer?.isSafe, props.origin],
[chainId, isCreation, onSubmit, trigger, signer?.isSafe, customOrigin],
)

const onRoleExecutionSubmit = useCallback<typeof onFormSubmit>(
Expand All @@ -124,6 +126,49 @@ export const SignOrExecuteForm = ({
[onFormSubmit],
)

const onNoteSubmit = useCallback(
(note: string) => {
setCustomOrigin(encodeTxNote(note, props.origin))
},
[setCustomOrigin, props.origin],
)

const getForm = () => {
const commonProps = {
...props,
safeTx,
isCreation,
origin: customOrigin,
onSubmit: onFormSubmit,
}
if (isCounterfactualSafe && !isProposing) {
return <CounterfactualForm {...commonProps} onlyExecute />
}

if (!isCounterfactualSafe && willExecute && !isProposing) {
return <ExecuteForm {...commonProps} />
}

if (!isCounterfactualSafe && willExecuteThroughRole) {
return (
<ExecuteThroughRoleForm
{...commonProps}
role={(allowingRole || mostLikelyRole)!}
safeTxError={safeTxError}
onSubmit={onRoleExecutionSubmit}
/>
)
}

if (!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isProposing) {
return <SignForm {...commonProps} isBatchable={isBatchable} />
}

clovisdasilvaneto marked this conversation as resolved.
Show resolved Hide resolved
if (isProposing) {
return <ProposerForm {...commonProps} onSubmit={onProposerFormSubmit} />
}
}

return (
<>
<TxCard>
Expand All @@ -149,6 +194,8 @@ export const SignOrExecuteForm = ({

{!isCounterfactualSafe && !props.isRejection && <TxChecks />}

<TxNoteForm isCreation={isCreation ?? false} onSubmit={onNoteSubmit} txDetails={props.txDetails} />

<SignerForm willExecute={willExecute} />

<TxCard>
Expand Down Expand Up @@ -179,32 +226,7 @@ export const SignOrExecuteForm = ({

<Blockaid />

{isCounterfactualSafe && !isProposing && (
<CounterfactualForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} onlyExecute />
)}
{!isCounterfactualSafe && willExecute && !isProposing && (
<ExecuteForm {...props} safeTx={safeTx} isCreation={isCreation} onSubmit={onFormSubmit} />
)}
{!isCounterfactualSafe && willExecuteThroughRole && (
<ExecuteThroughRoleForm
{...props}
safeTx={safeTx}
safeTxError={safeTxError}
onSubmit={onRoleExecutionSubmit}
role={(allowingRole || mostLikelyRole)!}
/>
)}
{!isCounterfactualSafe && !willExecute && !willExecuteThroughRole && !isProposing && (
<SignForm
{...props}
safeTx={safeTx}
isBatchable={isBatchable}
isCreation={isCreation}
onSubmit={onFormSubmit}
/>
)}

{isProposing && <ProposerForm {...props} safeTx={safeTx} onSubmit={onProposerFormSubmit} />}
{getForm()}
</TxCard>
</>
)
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/tx/SignOrExecuteForm/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type TxActions = {
origin?: string,
isRelayed?: boolean,
) => Promise<string>
signProposerTx: (safeTx?: SafeTransaction) => Promise<string>
signProposerTx: (safeTx?: SafeTransaction, origin?: string) => Promise<string>
proposeTx: (safeTx: SafeTransaction, txId?: string, origin?: string) => Promise<TransactionDetails>
}

Expand Down Expand Up @@ -135,14 +135,14 @@ export const useTxActions = (): TxActions => {
return tx.txId
}

const signProposerTx: TxActions['signProposerTx'] = async (safeTx) => {
const signProposerTx: TxActions['signProposerTx'] = async (safeTx, origin) => {
assertTx(safeTx)
assertProvider(wallet?.provider)
assertOnboard(onboard)

const signedTx = await dispatchProposerTxSigning(safeTx, wallet)

const tx = await _propose(wallet.address, signedTx)
const tx = await _propose(wallet.address, signedTx, undefined, origin)
return tx.txId
}

Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/features/tx-notes/TxNote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Tooltip, Typography, Stack } from '@mui/material'
import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk'
import InfoIcon from '@/public/images/notifications/info.svg'
import { isMultisigDetailedExecutionInfo } from '@/utils/transaction-guards'
import EthHashInfo from '@/components/common/EthHashInfo'

export function TxNote({ txDetails }: { txDetails: TransactionDetails | undefined }) {
// @FIXME: update CGW types to include note
const note = (txDetails as TransactionDetails & { note: string | null })?.note

if (!note) return null

const creator =
isMultisigDetailedExecutionInfo(txDetails?.detailedExecutionInfo) && txDetails?.detailedExecutionInfo.proposer

return (
<div>
<Typography variant="h5" display="flex" alignItems="center" justifyItems="center">
Note
<Tooltip
title={
<Stack direction="row" gap={1}>
<span>By </span>
{creator ? (
<EthHashInfo avatarSize={20} address={creator.value} showName onlyName />
) : (
<span>transaction creator</span>
)}
</Stack>
}
arrow
>
<Typography color="text.secondary" component="span" height="1em">
<InfoIcon height="100%" />
</Typography>
</Tooltip>
</Typography>

<Typography p={2} mt={1} borderRadius={1} bgcolor="background.main">
{note}
</Typography>
</div>
)
}
16 changes: 16 additions & 0 deletions apps/web/src/features/tx-notes/TxNoteForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { TransactionDetails } from '@safe-global/safe-gateway-typescript-sdk'
import TxCard from '@/components/tx-flow/common/TxCard'
import { TxNote } from './TxNote'
import { TxNoteInput } from './TxNoteInput'

export function TxNoteForm({
isCreation,
txDetails,
onSubmit,
}: {
isCreation: boolean
txDetails?: TransactionDetails
onSubmit: (note: string) => void
}) {
return <TxCard>{isCreation ? <TxNoteInput onSubmit={onSubmit} /> : <TxNote txDetails={txDetails} />}</TxCard>
}
58 changes: 58 additions & 0 deletions apps/web/src/features/tx-notes/TxNoteInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useCallback, useState } from 'react'
import { InputAdornment, Stack, TextField, Typography } from '@mui/material'
import InfoIcon from '@/public/images/notifications/info.svg'
import { MODALS_EVENTS, trackEvent } from '@/services/analytics'

const MAX_NOTE_LENGTH = 120

export const TxNoteInput = ({ onSubmit }: { onSubmit: (note: string) => void }) => {
const [note, setNote] = useState('')

const onInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNote(e.target.value)
}, [])

const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onSubmit(e.target.value.slice(0, MAX_NOTE_LENGTH))
trackEvent(MODALS_EVENTS.ADD_TX_NOTE)
},
[onSubmit],
)

return (
<>
<Stack direction="row" alignItems="flex-end" gap={1}>
<Typography variant="h5">What does this transaction do?</Typography>
<Typography variant="body2" color="text.secondary">
Optional
</Typography>
</Stack>

<TextField
name="note"
label="Add note"
fullWidth
slotProps={{
htmlInput: { maxLength: MAX_NOTE_LENGTH },
input: {
endAdornment: (
<InputAdornment position="end">
<Typography variant="caption" mt={3}>
{note.length}/{MAX_NOTE_LENGTH}
</Typography>
</InputAdornment>
),
},
}}
onInput={onInput}
onChange={onChange}
/>

<Typography variant="caption" color="text.secondary" display="flex" alignItems="center">
<InfoIcon height="1.2em" />
This note will be publicly visible and accessible to anyone.
</Typography>
</>
)
}
31 changes: 31 additions & 0 deletions apps/web/src/features/tx-notes/encodeTxNote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { faker } from '@faker-js/faker'
import { encodeTxNote } from './encodeTxNote'

describe('encodeTxNote', () => {
it('should encode tx note with an existing origin', () => {
const note = faker.lorem.sentence()
const url = faker.internet.url()
const origin = JSON.stringify({ url })
const result = encodeTxNote(note, origin)
expect(result).toEqual(JSON.stringify({ url, note }, null, 0))
})

it('should encode tx note with an empty origin', () => {
const note = faker.lorem.sentence()
const result = encodeTxNote(note)
expect(result).toEqual(JSON.stringify({ note }, null, 0))
})

it('should encode tx note with an invalid origin', () => {
const note = faker.lorem.sentence()
const result = encodeTxNote(note, 'sdfgdsfg')
expect(result).toEqual(JSON.stringify({ note }, null, 0))
})

it('should trim the note if origin exceeds the max length', () => {
const note = 'a'.repeat(200)
const url = 'http://example.com'
const result = encodeTxNote(note, JSON.stringify({ url }))
expect(result).toEqual(JSON.stringify({ url, note: 'a'.repeat(172) }, null, 0))
})
})
Loading
Loading