Skip to content

Commit cd63182

Browse files
authored
Merge pull request #2317 from AmbireTech/feature/smooth-max-transfer
Smooth max transfer
2 parents 711c53b + 507b0cf commit cd63182

10 files changed

Lines changed: 552 additions & 20 deletions

File tree

src/controllers/signAccountOp/signAccountOp.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ import {
150150
getUnknownTokenWarning,
151151
SignAccountOpType
152152
} from './helper'
153+
import { canFeeOptionCoverAmount, isTransferredTokenFeeOption } from '../../libs/account/feeOptions'
153154

154155
export enum SigningStatus {
155156
EstimationError = 'estimation-error',
@@ -906,7 +907,10 @@ export class SignAccountOpController extends EventEmitter implements ISignAccoun
906907
this.canBroadcast
907908
) {
908909
const identifier = getFeeSpeedIdentifier(this.selectedOption, this.accountOp.accountAddr)
909-
if (this.hasSpeeds(identifier))
910+
if (
911+
this.hasSpeeds(identifier) &&
912+
!this.#shouldSuppressTransferFeeSelectionError(this.selectedOption)
913+
)
910914
errors.push({
911915
title: 'Please select a token and an account for paying the gas fee.'
912916
})
@@ -917,6 +921,7 @@ export class SignAccountOpController extends EventEmitter implements ISignAccoun
917921
this.selectedOption &&
918922
this.accountOp.gasFeePayment &&
919923
this.selectedOption.availableAmount < this.accountOp.gasFeePayment.amount &&
924+
!this.#shouldSuppressTransferFeeSelectionError(this.selectedOption) &&
920925
this.canBroadcast
921926
) {
922927
const speedCoverage = []
@@ -1650,12 +1655,12 @@ export class SignAccountOpController extends EventEmitter implements ISignAccoun
16501655
const aId = getFeeSpeedIdentifier(a, this.accountOp.accountAddr)
16511656
const aSlow = this.feeSpeeds[aId]?.find((speed) => speed.type === 'slow')
16521657
if (!aSlow) return 1
1653-
const aCanCoverFee = a.availableAmount >= aSlow.amount
1658+
const aCanCoverFee = canFeeOptionCoverAmount(a, this.accountOp, aSlow.amount)
16541659

16551660
const bId = getFeeSpeedIdentifier(b, this.accountOp.accountAddr)
16561661
const bSlow = this.feeSpeeds[bId]?.find((speed) => speed.type === 'slow')
16571662
if (!bSlow) return -1
1658-
const bCanCoverFee = b.availableAmount >= bSlow.amount
1663+
const bCanCoverFee = canFeeOptionCoverAmount(b, this.accountOp, bSlow.amount)
16591664

16601665
if (aCanCoverFee && !bCanCoverFee) return -1
16611666
if (!aCanCoverFee && bCanCoverFee) return 1
@@ -1681,13 +1686,14 @@ export class SignAccountOpController extends EventEmitter implements ISignAccoun
16811686
#getIsFeeOptionDisabled(feeOption: FeePaymentOption): boolean {
16821687
const id = getFeeSpeedIdentifier(feeOption, this.accountOp.accountAddr)
16831688
const speeds = this.feeSpeeds[id] ?? []
1689+
const isTransferredTokenOption = isTransferredTokenFeeOption(feeOption, this.accountOp)
16841690

16851691
const coversSlow = speeds.some(
16861692
(speed: SpeedCalc) =>
16871693
speed.type === FeeSpeed.Slow && feeOption.availableAmount >= speed.amount
16881694
)
16891695

1690-
if (!coversSlow) return true
1696+
if (!coversSlow && !isTransferredTokenOption) return true
16911697

16921698
const isExternal = this.accountKeyStoreKeys.some(
16931699
(keyStoreKey) => keyStoreKey.addr === feeOption.paidBy && keyStoreKey.isExternallyStored
@@ -1700,6 +1706,14 @@ export class SignAccountOpController extends EventEmitter implements ISignAccoun
17001706
return false
17011707
}
17021708

1709+
#shouldSuppressTransferFeeSelectionError(feeOption?: FeePaymentOption): boolean {
1710+
return (
1711+
this.#type === 'one-click-transfer' &&
1712+
!!feeOption &&
1713+
isTransferredTokenFeeOption(feeOption, this.accountOp)
1714+
)
1715+
}
1716+
17031717
/**
17041718
* Obtain the native token ratio in relation to a fee token.
17051719
*

src/controllers/transfer/transfer.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-use-before-define */
22
/* eslint-disable @typescript-eslint/no-floating-promises */
33

4-
import { ZeroAddress } from 'ethers'
4+
import { ZeroAddress, formatUnits } from 'ethers'
55

66
import { expect } from '@jest/globals'
77

@@ -117,6 +117,43 @@ describe('Transfer Controller', () => {
117117
})
118118
expect(transferController.amount).toBe('1')
119119
})
120+
test('should set max amount minus fee when the selected fee token matches the transfer token', async () => {
121+
const { transferController, tokens } = await prepareTest()
122+
123+
const nativeToken = tokens.find((t) => t.address === ZeroAddress && t.chainId === 1n)!
124+
const feeAmount = 1_000_000_000_000_000n
125+
126+
await transferController.update({
127+
selectedToken: nativeToken
128+
})
129+
;(transferController as any).signAccountOpController = {
130+
accountOp: {
131+
gasFeePayment: {
132+
amount: feeAmount,
133+
inToken: nativeToken.address,
134+
feeTokenChainId: nativeToken.chainId
135+
}
136+
},
137+
selectedOption: {
138+
paidBy: account.addr,
139+
token: {
140+
...nativeToken,
141+
flags: {
142+
...nativeToken.flags,
143+
onGasTank: false
144+
}
145+
}
146+
}
147+
}
148+
149+
await transferController.update({
150+
shouldSetMaxAmount: true
151+
})
152+
153+
expect(transferController.amount).toBe(
154+
formatUnits(nativeToken.amount - feeAmount, nativeToken.decimals)
155+
)
156+
})
120157
test('should set validation form messages', async () => {
121158
const { transferController, tokens } = await prepareTest()
122159

src/controllers/transfer/transfer.ts

Lines changed: 134 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ import { HumanizerMeta } from '../../libs/humanizer/interfaces'
2424
import { randomId } from '../../libs/humanizer/utils'
2525
import { TokenResult } from '../../libs/portfolio'
2626
import { getTokenAmount, getTokenBalanceInUSD } from '../../libs/portfolio/helpers'
27-
import { getSanitizedAmount } from '../../libs/transfer/amount'
27+
import {
28+
getAmountAfterFeeReserve,
29+
getAmountAfterFeeSync,
30+
getSanitizedAmount
31+
} from '../../libs/transfer/amount'
2832
import { getTransferRequestParams } from '../../libs/transfer/userRequest'
2933
import {
3034
validateSendTransferAddress,
@@ -126,6 +130,8 @@ export class TransferController extends EventEmitter implements ITransferControl
126130

127131
#shouldSkipTransactionQueuedModal: boolean = false
128132

133+
#isMaxAmountSelected: boolean = false
134+
129135
#accounts: IAccountsController
130136

131137
#keystore: IKeystoreController
@@ -402,6 +408,7 @@ export class TransferController extends EventEmitter implements ITransferControl
402408

403409
if (!token || Number(getTokenAmount(token)) === 0) {
404410
this.#selectedToken = null
411+
this.#isMaxAmountSelected = false
405412
this.#setAmountAndNotifyUI('')
406413
this.#setAmountInFiatAndNotifyUI('')
407414
this.amountFieldMode = 'token'
@@ -416,6 +423,7 @@ export class TransferController extends EventEmitter implements ITransferControl
416423
prevSelectedToken?.address !== token?.address ||
417424
prevSelectedToken?.chainId !== token?.chainId
418425
) {
426+
this.#isMaxAmountSelected = false
419427
if (!token.priceIn.length) this.amountFieldMode = 'token'
420428
this.#setAmountAndNotifyUI('')
421429
this.#setAmountInFiatAndNotifyUI('')
@@ -431,12 +439,7 @@ export class TransferController extends EventEmitter implements ITransferControl
431439
}
432440

433441
get maxAmount(): string {
434-
if (
435-
!this.selectedToken ||
436-
getTokenAmount(this.selectedToken) === 0n ||
437-
typeof this.selectedToken.decimals !== 'number'
438-
)
439-
return '0'
442+
if (!this.selectedToken || getTokenAmount(this.selectedToken) === 0n) return '0'
440443

441444
return formatUnits(getTokenAmount(this.selectedToken), this.selectedToken.decimals)
442445
}
@@ -461,6 +464,7 @@ export class TransferController extends EventEmitter implements ITransferControl
461464
}
462465

463466
resetForm(shouldDestroyAccountOp = true) {
467+
this.#isMaxAmountSelected = false
464468
this.amount = ''
465469
this.amountInFiat = ''
466470
this.amountFieldMode = 'token'
@@ -587,12 +591,14 @@ export class TransferController extends EventEmitter implements ITransferControl
587591
}
588592
// If we do a regular check the value won't update if it's '' or '0'
589593
if (typeof amount === 'string') {
594+
this.#isMaxAmountSelected = false
590595
this.#setAmount(amount)
591596
}
592597

593598
if (shouldSetMaxAmount) {
599+
this.#isMaxAmountSelected = true
594600
this.amountFieldMode = 'token'
595-
this.#setAmount(this.maxAmount, true)
601+
this.#setTokenAmount(this.#getMaxAmountAfterFeeReservation(), true)
596602
}
597603

598604
if (addressState) {
@@ -760,6 +766,119 @@ export class TransferController extends EventEmitter implements ITransferControl
760766
}
761767
}
762768

769+
#setTokenAmount(amount: string, isProgrammaticUpdate = false) {
770+
const amountFieldMode = this.amountFieldMode
771+
772+
this.amountFieldMode = 'token'
773+
this.#setAmount(amount, isProgrammaticUpdate)
774+
this.amountFieldMode = amountFieldMode
775+
}
776+
777+
#getMaxAmountAfterFeeReservation() {
778+
if (!this.selectedToken) return this.maxAmount
779+
780+
const totalTokenAmount = getTokenAmount(this.selectedToken)
781+
const gasFeePayment = this.signAccountOpController?.accountOp.gasFeePayment
782+
783+
if (!this.#shouldReserveFeeFromTransferredToken() || !gasFeePayment) {
784+
return formatUnits(totalTokenAmount, this.selectedToken.decimals)
785+
}
786+
787+
return formatUnits(
788+
getAmountAfterFeeReserve(totalTokenAmount, gasFeePayment.amount),
789+
this.selectedToken.decimals
790+
)
791+
}
792+
793+
#shouldReserveFeeFromTransferredToken() {
794+
const gasFeePayment = this.signAccountOpController?.accountOp.gasFeePayment
795+
const selectedFeeOption = this.signAccountOpController?.selectedOption
796+
const selectedToken = this.selectedToken
797+
const accountAddr = this.#selectedAccount.account?.addr.toLowerCase()
798+
799+
if (!accountAddr || !gasFeePayment || !selectedFeeOption || !selectedToken) return false
800+
if (selectedFeeOption.token.flags.onGasTank) return false
801+
if (selectedFeeOption.paidBy.toLowerCase() !== accountAddr) return false
802+
803+
const selectedTokenAddress = selectedToken.address.toLowerCase()
804+
805+
return (
806+
!!accountAddr &&
807+
!!gasFeePayment &&
808+
!!selectedFeeOption &&
809+
selectedFeeOption.paidBy.toLowerCase() === accountAddr &&
810+
selectedFeeOption.token.chainId === selectedToken.chainId &&
811+
selectedFeeOption.token.address.toLowerCase() === selectedTokenAddress &&
812+
gasFeePayment.inToken.toLowerCase() === selectedTokenAddress &&
813+
(!gasFeePayment.feeTokenChainId || gasFeePayment.feeTokenChainId === selectedToken.chainId)
814+
)
815+
}
816+
817+
#syncAmountWithFeeReservation(forceEmit?: boolean) {
818+
if (!this.amount || !this.selectedToken || typeof this.selectedToken.decimals !== 'number')
819+
return false
820+
821+
const totalTokenAmount = getTokenAmount(this.selectedToken)
822+
const shouldReserveFee = this.#shouldReserveFeeFromTransferredToken()
823+
const gasFeePayment = this.signAccountOpController?.accountOp.gasFeePayment
824+
const currentAmount = this.amount
825+
? parseUnits(
826+
getSafeAmountFromFieldValue(this.amount, this.selectedToken.decimals),
827+
this.selectedToken.decimals
828+
)
829+
: 0n
830+
const desiredAmount = getAmountAfterFeeSync({
831+
currentAmount,
832+
totalAmount: totalTokenAmount,
833+
fee: shouldReserveFee ? gasFeePayment?.amount || 0n : 0n,
834+
shouldReserveFee,
835+
isMaxAmountSelected: this.#isMaxAmountSelected
836+
})
837+
838+
if (currentAmount === desiredAmount) return false
839+
840+
this.#setTokenAmount(
841+
desiredAmount === 0n ? '0' : formatUnits(desiredAmount, this.selectedToken.decimals),
842+
true
843+
)
844+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
845+
this.syncSignAccountOp()
846+
this.propagateUpdate(forceEmit)
847+
848+
return true
849+
}
850+
851+
/**
852+
* When doing a MAX transfer or a close to MAX transfer out,
853+
* if the selected fee token is the same as the transfer token,
854+
* we automatically adjust the transfer amount so the user
855+
* can successfully broadcast. For that, we put an additional
856+
* warning telling him why this is happening
857+
*/
858+
get amountAdjustmentWarning(): Validation | null {
859+
if (!this.amount || !this.selectedToken || !this.#shouldReserveFeeFromTransferredToken()) {
860+
return null
861+
}
862+
863+
const gasFeePayment = this.signAccountOpController?.accountOp.gasFeePayment
864+
if (!gasFeePayment) return null
865+
866+
const currentAmount = parseUnits(
867+
getSafeAmountFromFieldValue(this.amount, this.selectedToken.decimals),
868+
this.selectedToken.decimals
869+
)
870+
const totalTokenAmount = getTokenAmount(this.selectedToken)
871+
872+
if (currentAmount > 0n && currentAmount + gasFeePayment.amount >= totalTokenAmount) {
873+
return {
874+
severity: 'warning',
875+
message: 'Amount adjusted to cover blockchain fees'
876+
}
877+
}
878+
879+
return null
880+
}
881+
763882
get hasPersistedState() {
764883
return !!(this.amount || this.amountInFiat || this.addressState.fieldValue)
765884
}
@@ -860,7 +979,8 @@ export class TransferController extends EventEmitter implements ITransferControl
860979
calls,
861980
meta: {
862981
paymasterService: getAmbirePaymasterService(baseAcc, this.#relayerUrl),
863-
topUpAmount
982+
topUpAmount,
983+
allowTransferFeeTokenSelfReserve: true
864984
}
865985
}
866986

@@ -899,7 +1019,7 @@ export class TransferController extends EventEmitter implements ITransferControl
8991019
shouldSimulate: false,
9001020
onBroadcastSuccess: async (props) => {
9011021
const { submittedAccountOp } = props
902-
this.#portfolio.simulateAccountOp(props.accountOp).then(() => {
1022+
void this.#portfolio.simulateAccountOp(props.accountOp).then(() => {
9031023
this.#portfolio.markSimulationAsBroadcasted(accountOp.accountAddr, accountOp.chainId)
9041024
})
9051025

@@ -918,6 +1038,8 @@ export class TransferController extends EventEmitter implements ITransferControl
9181038
})
9191039

9201040
this.signAccountOpController.onUpdate((forceEmit) => {
1041+
this.#syncAmountWithFeeReservation(forceEmit)
1042+
9211043
this.propagateUpdate(forceEmit)
9221044

9231045
if (this.signAccountOpController?.broadcastStatus === 'SUCCESS') {
@@ -1015,7 +1137,8 @@ export class TransferController extends EventEmitter implements ITransferControl
10151137
maxAmountInFiat: this.maxAmountInFiat,
10161138
shouldSkipTransactionQueuedModal: this.shouldSkipTransactionQueuedModal,
10171139
hasPersistedState: this.hasPersistedState,
1018-
isRecipientAddressViewOnly: this.isRecipientAddressViewOnly
1140+
isRecipientAddressViewOnly: this.isRecipientAddressViewOnly,
1141+
amountAdjustmentWarning: this.amountAdjustmentWarning
10191142
}
10201143
}
10211144
}

src/libs/account/EOA7702.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { TokenResult } from '../portfolio'
1717
import { isNative } from '../portfolio/helpers'
1818
import { UserOperation } from '../userOperation/types'
1919
import { BaseAccount } from './BaseAccount'
20+
import { isTransferredTokenFeeOption } from './feeOptions'
2021

2122
// this class describes an EOA that CAN transition to 7702
2223
// even if it is YET to transition to 7702
@@ -78,7 +79,7 @@ export class EOA7702 extends BaseAccount {
7879
opt.paidBy === this.account.addr &&
7980
(isNative(opt.token) ||
8081
(!isDelegating &&
81-
opt.availableAmount > 0n &&
82+
(opt.availableAmount > 0n || isTransferredTokenFeeOption(opt, op)) &&
8283
estimation.bundlerEstimation &&
8384
estimation.bundlerEstimation.paymaster.isUsable()))
8485
)

0 commit comments

Comments
 (0)