Skip to content

Conversation

p2arthur
Copy link
Contributor

@p2arthur p2arthur commented Sep 3, 2025

#Optional Sender for Simulation

Status: Open
Goal: Let users simulate transactions without manually entering a sender. If the sender is left empty, we auto-resolve the “best” account by network. The Send button still requires explicit senders on all transactions.


Summary of Changes

  • Optional sender implemented in browser runtime.
  • Auto-resolution rules when sender is empty:
    • LocalNet: local dispenser account
    • TestNet: fee sink (or dispenser API address)
    • MainNet: fee sink address
    • Custom networks: not supported → show clear error
  • UX/Styling
    • Auto-populated senders are subtly highlighted (e.g., bg-red-50) and show an indicator/tooltip:
      “This address was auto-populated for simulation.”
    • Send remains disabled until every txn has a sender; hover tooltip explains why.

Integrated Builders (10/15)

  1. Payment (pay) — PaymentTransactionBuilder
  2. Account Close (pay) — AccountCloseTransactionBuilder
  3. Application Call (appl) — AppCallTransactionBuilder
  4. ABI Method Call (appl) — MethodCallTransactionBuilder
  5. Application Create (appl) — ApplicationCreateTransactionBuilder
  6. Application Update (appl) — ApplicationUpdateTransactionBuilder
  7. Asset Transfer (axfer) — AssetTransferTransactionBuilder
  8. Asset opt-in (axfer) — AssetOptInTransactionBuilder
  9. Asset opt-out (axfer) — AssetOptOutTransactionBuilder
  10. Asset clawback (axfer) — AssetClawbackTransactionBuilder
  11. Asset create (acfg) — AssetCreateTransactionBuilder
  12. Asset reconfigure (acfg) — AssetReconfigureTransactionBuilder
  13. Asset destroy (acfg) — AssetDestroyTransactionBuilder
  14. Asset freeze (afrz) — AssetFreezeTransactionBuilder
  15. Key registration (keyreg) — KeyRegistrationTransactionBuilder

Acceptance Criteria (Quick Map)

  • Sender can be omitted for simulation; resolved per network.
  • Send disabled until all txns have senders (+ hover tooltip).
  • Resolution rules: LocalNet/TestNet/MainNet; Custom → error.

Testing Notes

  1. Leave sender empty on LocalNet/TestNet/MainNet → auto-populated sender + highlight + tooltip.
  2. On custom network with empty sender → clear error.
  3. Send disabled if any txn lacks sender; hover explains reason. - Can simulate with using the auto populated sender address
  4. When a manual sender is provided → no auto highlight/tooltip.

Follow-ups (Post-Draft)

  • Complete the remaining 5 builders.
  • Unit tests for defineSenderAddress per network.
  • Integration tests to check if the process of auto populating works correctly and is displayed in the ui
  • Brief README note on optional sender + simulation UX.
image image

@cloudflare-workers-and-pages
Copy link

cloudflare-workers-and-pages bot commented Sep 4, 2025

Deploying algokit-lora with  Cloudflare Pages  Cloudflare Pages

Latest commit: 206971b
Status: ✅  Deploy successful!
Preview URL: https://2890fb69.algokit-lora.pages.dev
Branch Preview URL: https://feat-optional-sender.algokit-lora.pages.dev

View logs

@p2arthur p2arthur marked this pull request as ready for review September 12, 2025 05:54
Copy link
Contributor

@PatrickDinh PatrickDinh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial comments. I'll resume my review later.

{autoPopulated && (
<span className="group ml-1 cursor-help text-yellow-500">
<span>?</span>
<div className="absolute z-10 hidden rounded-sm border-2 border-gray-300/20 p-1 group-hover:block">auto populated</div>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL group-hover

<div className="flex items-center overflow-hidden">
{link}
{autoPopulated && (
<span className="group ml-1 cursor-help text-yellow-500">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: there is double spaces here

<dl key={index} className={cn('grid grid-cols-subgrid col-span-2')}>
<dt className={cn('font-medium', dtClassName)}>{item.dt}</dt>
<dd className={cn('overflow-ellipsis whitespace-normal overflow-hidden')}>
<dd className={cn('overflow-ellipsis whitespace-normal overflow-hidden', item.highlightedDD && 'font-medium text-green-500')}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be implemented as a ddClassNames props, i.e.

<dd className={cn('overflow-ellipsis whitespace-normal overflow-hidden', item.ddClassNames)}

the consumer if this component can decide how they want to style it. This way it's more flexible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, will try that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest we don't need that change. I think it slipped while I was trying to figure out the inner workings of the transaction wizard in order to add highlighting if auto populated.

Reverting this change

Comment on lines 52 to 60
export const SenderSchema = z.object({
value: z.string().optional(),
resolvedAddress: z.string().optional(),
})

// TODO Arthur - Added this shape to make the sender optional in the forms that required it
export const optionalSenderFieldShape = {
sender: SenderSchema,
} as const
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't looked at this code for a while so maybe I missed something.
Something isn't quite right here because the value field will miss the isAddress and isNfd validations.
Is it possible to use the optionalAddressFieldSchema?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we can. Just updated it to use the optionalAddressFieldSchema instead
image

id: transaction?.id ?? randomGuid(),
type: BuildableTransactionType.AccountClose,
sender: data.sender,
sender: await defineSenderAddress(data.sender!, networkId),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need ! in data.sender!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the file name should be snake-case to follow the project file name convention


const ensureSender = (sender: AddressOrNfd | undefined) => {
const validSender = sender ?? {
value: 'TGIPEOKUFC5JFTPFMXGSZWOGOFA7THFZXUTRLQEOH3RD3LGI6QEEWJNML4',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this address? you should avoid using magic string and define a constant?

export type AddressOrNfd = {
value: Address | Nfd
resolvedAddress: Address
autoPopulated?: boolean
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding autoPopulated field to this type doesn't feel right. This type is used across the app to represent an address or nfd, it shouldn't have to concern about whether the data was auto populated or not.
As a suggestion, I'd create another type

export type TransactionSender {
    value: AddressOrNfd
    autoPopulated: boolean
}

to represent this logic

Copy link
Contributor Author

@p2arthur p2arthur Sep 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried many solutions but the one that generated the least amount of collateral errors was Solved it like this

image

type CommonBuildTransactionResult = {
id: string
sender: AddressOrNfd
sender: AddressOrNfd | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be undefined? maybe I missed something but after a quick look I see that the value of the sender field is always populated with defineSenderAddress function

dt: 'Sender',
dd: <AddressOrNfdLink address={params.sender} />,
dd: (
<AddressOrNfdLink
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar logic to the comment on AddressOrNfd type, I think we should create another component that wraps the AddressOrNfdLink component to display the sender of a transaction.

resolvedAddress: 'TGIPEOKUFC5JFTPFMXGSZWOGOFA7THFZXUTRLQEOH3RD3LGI6QEEWJNML4',
}

invariant(validSender, 'Sender must be set')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this invariant is not needed, validSender must be a truthy from above.

@PatrickDinh
Copy link
Contributor

There is an UX issue that I think we should review, in the below recording:

  • I created a payment txn without the sender
  • the default sender was set and there is an indicator shown
  • when I edit the transaction, the sender field is set to the default sender. I think this shouldn't happen
  • when I save the transaction, there is no "auto populated" indicator because the form thinks that I set the sender field.
    https://github.com/user-attachments/assets/c11acf9e-903b-4df6-970e-fdd34b204596

Comment on lines 334 to 343
transactions.forEach((transaction) => {
if (transaction.sender?.autoPopulated === true) setOnlySimulateOptionalSender(true)
})

if (onlySimulateOptionalSender) {
return {
disabled: true,
disabledReason: onlySimulateOptionalSenderMessage,
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems that onlySimulateOptionalSender is only used within this useMemo, I don't think you need a separate onlySimulateOptionalSender state

Comment on lines 155 to 166
{table.getRowModel().rows?.length ? (
table
.getRowModel()
.rows.map((row) => (
table.getRowModel().rows.map((row) => {
return (
<TransactionRow
key={row.id}
row={row}
transactionPositions={transactionPositions}
onEditTransaction={onEditTransaction}
onEditResources={onEditResources}
/>
))
)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this need to be changed? If it doesn't need to be, I suggest to revert it to make PR review easier.

Comment on lines 20 to 26

import defineSenderAddress from '../utils/define-sender-address'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need an extra empty line above this. Also, I think resolveSenderAddress is a better name. The logic inside this function doesn't define anything new.

import { TransactionBuilderNoteField } from './transaction-builder-note-field'
import { asAddressOrNfd } from '../mappers/as-address-or-nfd'
import { ActiveWalletAccount } from '@/features/wallet/types/active-wallet'

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove empty line here

field: 'sender',
label: 'Sender',
helpText: 'Account to pay from. Sends the transaction and pays the fee',
helpText: 'Account to pay from. Sends the transaction and pays the fee - optional for simulating',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason why the helpText is updated here but not for other transaction types?

) : (
<div className="flex items-center overflow-hidden">
{link}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: no need this newline

export default function TransactionSenderLink({
address,
short,

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this empty line

import { Address } from '../data/types'
import { cn } from '@/features/common/utils'

export type AddressOrNfdLinkProps = PropsWithChildren<{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename this type to avoid conflicts with address-or-ndf-link.tsx. If it isn't used anywhere else, I'd just call it Props

Suggested change
export type AddressOrNfdLinkProps = PropsWithChildren<{
type Props = PropsWithChildren<{

pera: 'Pera',
exodus: 'Exodus',
lute: 'Lute',
// The below providers aren't used
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should still keep this comment

resolvedAddress: addressOrAccount,
} satisfies AddressOrNfd
}
export const asOptionalAddressOrNfd = (transactionSender?: Partial<TransactionSender>): TransactionSender => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function doesn't look right, the name is asOptionalAddressOrNfd but return TransactionSender:

  • The type in the function name doesn't match with the return type
  • The name implies optional but the return type is not
    I think you should create a new function just to handle transaction sender and revert this back to the original state.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick hack. I think you can use something like below

export const asTransactionSender = (transactionSender?: Partial<TransactionSender>): TransactionSender | undefined => {
  if (!transactionSender) return undefined
  if (transactionSender.autoPopulated) return undefined

  return transactionSender.value && transactionSender.resolvedAddress
    ? ({
        value: transactionSender.value,
        resolvedAddress: transactionSender.resolvedAddress,
        autoPopulated: false,
      } satisfies TransactionSender)
    : undefined
}

You will need to test to ensure it works. Additionally, it should be in a new file as-transaction-sender

sender: await resolveSenderAddress(data.sender),
closeRemainderTo: data.closeRemainderTo,
receiver: asOptionalAddressOrNfd(data.receiver),
receiver: { value: data.receiver.value!, resolvedAddress: data.receiver.resolvedAddress! },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why the logic for receiver needed to be updated, maybe it was related to the asOptionalAddressOrNfd function I commented on earlier, you should revert this.

export type BuildPaymentTransactionResult = CommonBuildTransactionResult & {
type: BuildableTransactionType.Payment
receiver: AddressOrNfd
sender: AddressOrNfd
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should remove this, the sender is already in CommonBuildTransactionResult

return {
sender: transaction.sender.resolvedAddress,
receiver: transaction.receiver ? transaction.receiver.resolvedAddress : transaction.sender.resolvedAddress,
sender: ensureSender(transaction.sender),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you need to call ensureSender anymore?

Comment on lines +198 to +203
const sendButton = await waitFor(() => {
const sendButton = component.getByRole('button', { name: sendButtonLabel })
expect(sendButton).not.toBeDisabled()
return sendButton!
})
await user.click(sendButton)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought that if the sender is auto populated only "Simulate" is allowed but this test seems to be able to "Send" transaction, can you please confirm?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have you had a chance to look into this?

Comment on lines +8 to +16
export type Props = PropsWithChildren<{
address: Address
short?: boolean
className?: string
showCopyButton?: boolean
showQRButton?: boolean
nfd?: Nfd
autoPopulated?: boolean
}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick

Suggested change
export type Props = PropsWithChildren<{
address: Address
short?: boolean
className?: string
showCopyButton?: boolean
showQRButton?: boolean
nfd?: Nfd
autoPopulated?: boolean
}>
export type Props = AddressOrNfdLinkProps & {
autoPopulated?: boolean
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type of the address field is incorrect, it should be

address: string | Address;

The Address is from algosdk

<AddressOrNfdLink
address={address}
short={short}
className={cn(autoPopulated && 'text-yellow-500')}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the className prop needs to be passed in here too, I think this would become something like

className={cn(className, autoPopulated && 'text-yellow-500')}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can rewrite this bit to

export default function TransactionSenderLink(props: Props) {
  const { autoPopulated, className, ...rest } = props

  return (
    <div className="flex items-center">
      <AddressOrNfdLink className={cn(className, autoPopulated && 'text-yellow-500')} {...rest} />
      ...

sender: await resolveSenderAddress(data.sender),
closeRemainderTo: data.closeRemainderTo,
receiver: asOptionalAddressOrNfd(data.receiver),
receiver: asAddressOrNfd(data.receiver.value!),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think something isn't right here. This change potentially breaks existing logic. On main, the receiver field is

receiver: asOptionalAddressOrNfd(data.receiver),

this is an optional field.
In this PR, it was changed to

receiver: asAddressOrNfd(data.receiver.value!),

Some concerns:

  • Now it isn't an optional field anymore. This is incorrect. For an account close transaction, the receiver field is: "Account to receive the amount. Leave blank if 'Close remainder to' account should receive the full balance"


export const senderFieldSchema = { sender: addressFieldSchema }

// TODO Arthur - Added this shape to make the sender optional in the forms that required it
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this TODO anymore?

approvalProgram: transaction.approvalProgram,
clearStateProgram: transaction.clearStateProgram,
sender: transaction.sender,
sender: asTransactionSender(transaction.sender),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need the method asTransactionSender, I could be completely wrong here but you should try something simple like

sender: transaction.sender?.autoPopulated ? undefined : transaction.sender

In the above logic, the sender should be set to undefined if it was autoPopulated

import { betanetId, mainnetId, testnetId, fnetId, localnetId } from '@/features/network/data'
import { algorandClient } from '@/features/common/data/algo-client'

export default async function resolveSenderAddress<T extends OptionalSenderFieldSchema>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the generic <T extends ...> required here?


export default async function resolveSenderAddress<T extends OptionalSenderFieldSchema>(
data: T
): Promise<AddressOrNfd & TransactionSender> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can just be Promise<TransactionSender>

}, [activeAddress, commonButtonDisableProps, requireSignaturesOnSimulate])

const sendButtonDisabledProps = useMemo(() => {
// derive it, don't store it
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this comment here?

export const assetOptOutFormSchema = z.object({
...commonSchema,
...senderFieldSchema,
...optionalSenderFieldShape,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can skip the optionalSenderFieldShape and use

sender: optionalAddressFieldSchema

directly. It's just 1 line of code and it will make reading a lot easier

Comment on lines 51 to +56
export const senderFieldSchema = { sender: addressFieldSchema }

// TODO Arthur - Added this shape to make the sender optional in the forms that required it
export const optionalSenderFieldShape = {
sender: optionalAddressFieldSchema,
} as const
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is the new const optionalSenderFieldShape introduced? Since this PR changing sender to optional, I think you can update

export const senderFieldSchema = { sender: optionalAddressFieldSchema }

Comment on lines +8 to +16
export type Props = PropsWithChildren<{
address: Address
short?: boolean
className?: string
showCopyButton?: boolean
showQRButton?: boolean
nfd?: Nfd
autoPopulated?: boolean
}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type of the address field is incorrect, it should be

address: string | Address;

The Address is from algosdk

{
dt: 'Sender',
dd: <AddressOrNfdLink address={params.sender} />,
dd: <TransactionSenderLink autoPopulated={txn.sender.autoPopulated} address={String(params.sender)} />,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After you fixed the TransactionSenderLink, you can replace String(params.sender) with params.sender

Comment on lines +69 to +70
value: Address | Nfd | undefined
resolvedAddress: Address | undefined
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these fields be undefined? I think not


return {
sender: transaction.sender.resolvedAddress,
sender: transaction.sender.resolvedAddress!,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the ! is needed here, feel like it is a result of the incorrect type

export type TransactionSender = {
  value: Address | Nfd | undefined
  resolvedAddress: Address | undefined
  autoPopulated?: boolean
}

that I pointed out earlier

value: params.sender,
resolvedAddress: params.sender,
},
sender: asTransactionSender({ value: params.sender!, resolvedAddress: params.sender!, autoPopulated: false }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveSenderAddress needs to be call here. This user flow should work exactly like when a user creates the transaction from the transaction wizard

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants