import { BigNumber } from '@ethersproject/bignumber' import { MaxUint160, MaxUint256 } from '@uniswap/permit2-sdk' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { DAI, USDC_MAINNET, USDT } from '../../src/constants/tokens' import { getTestSelector } from '../utils' /** Initiates a swap. */ function initiateSwap() { // The swap button is re-rendered once enabled, so we must wait until the original button is not disabled to re-select the appropriate button. cy.get('#swap-button').should('not.be.disabled') // Completes the swap. cy.get('#swap-button').click() cy.contains('Confirm swap').click() } describe('Permit2', () => { function setupInputs(inputToken: Token, outputToken: Token) { // Sets up a swap between inputToken and outputToken. cy.visit(`/swap/?inputCurrency=${inputToken.address}&outputCurrency=${outputToken.address}`) cy.get('#swap-currency-input .token-amount-input').type('0.01') } /** Asserts permit2 has a max approval for spend of the input token on-chain. */ function expectTokenAllowanceForPermit2ToBeMax(inputToken: Token) { // check token approval cy.hardhat() .then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: inputToken })) .then((allowance) => { Cypress.log({ name: `Token allowance: ${allowance.toString()}` }) cy.wrap(allowance).should('deep.equal', MaxUint256) }) } /** Asserts the universal router has a max permit2 approval for spend of the input token on-chain. */ function expectPermit2AllowanceForUniversalRouterToBeMax(inputToken: Token) { cy.hardhat() .then(({ approval, wallet }) => approval.getPermit2Allowance({ owner: wallet, token: inputToken })) .then((allowance) => { Cypress.log({ name: `Permit2 allowance: ${allowance.amount.toString()}` }) cy.wrap(allowance.amount).should('deep.equal', MaxUint160) // Asserts that the on-chain expiration is in 30 days, within a tolerance of 40 seconds. const THIRTY_DAYS_SECONDS = 2_592_000 const expected = Math.floor(Date.now() / 1000 + THIRTY_DAYS_SECONDS) cy.wrap(allowance.expiration).should('be.closeTo', expected, 40) }) } beforeEach(() => cy.hardhat().then(async (hardhat) => { await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(DAI, 1e18)) await hardhat.mine() }) ) describe('approval process (with intermediate screens)', () => { // Turn off automine so that intermediate screens are available to assert on. beforeEach(() => cy.hardhat({ automine: false })) it('swaps after completing full permit2 approval process', () => { setupInputs(DAI, USDC_MAINNET) initiateSwap() // verify that the modal retains its state when the window loses focus cy.window().trigger('blur') // Verify token approval cy.contains('Enable spending DAI on Uniswap') cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.get(getTestSelector('popups')).contains('Approved') expectTokenAllowanceForPermit2ToBeMax(DAI) // Verify permit2 approval cy.contains('Allow DAI to be used for swapping') cy.wait('@eth_signTypedData_v4') cy.wait('@eth_sendRawTransaction') cy.contains('Swap submitted') cy.hardhat().then((hardhat) => hardhat.mine()) cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') expectPermit2AllowanceForUniversalRouterToBeMax(DAI) }) it('swaps with existing permit approval and missing token approval', () => { setupInputs(DAI, USDC_MAINNET) cy.hardhat().then(async (hardhat) => { await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: DAI }) await hardhat.mine() }) initiateSwap() // Verify token approval cy.contains('Enable spending DAI on Uniswap') cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.get(getTestSelector('popups')).contains('Approved') expectTokenAllowanceForPermit2ToBeMax(DAI) // Verify transaction cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') }) /** * On mainnet, you have to revoke USDT approval before increasing it. * From the token contract: * To change the approve amount you first have to reduce the addresses` * allowance to zero by calling `approve(_spender, 0)` if it is not * already 0 to mitigate the race condition described here: * https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729 */ it('swaps USDT with existing permit, and existing but insufficient token approval', () => { cy.hardhat().then(async (hardhat) => { await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(USDT, 2e6)) await hardhat.mine() await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: USDT }, 1e6) await hardhat.mine() await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: USDT }) await hardhat.mine() }) setupInputs(USDT, USDC_MAINNET) cy.get('#swap-currency-input .token-amount-input').clear().type('2') initiateSwap() // Verify allowance revocation cy.contains('Reset USDT') cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.hardhat() .then(({ approval, wallet }) => approval.getTokenAllowanceForPermit2({ owner: wallet, token: USDT })) .should('deep.equal', BigNumber.from(0)) // Verify token approval cy.contains('Enable spending USDT on Uniswap') cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.get(getTestSelector('popups')).contains('Approved') expectTokenAllowanceForPermit2ToBeMax(USDT) // Verify transaction cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') }) it('swaps USDT with existing permit, and existing and sufficient token approval', () => { cy.hardhat().then(async (hardhat) => { await hardhat.fund(hardhat.wallet, CurrencyAmount.fromRawAmount(USDT, 2e6)) await hardhat.mine() await hardhat.approval.setTokenAllowanceForPermit2({ owner: hardhat.wallet, token: USDT }, 1e6) await hardhat.mine() await hardhat.approval.setPermit2Allowance({ owner: hardhat.wallet, token: USDT }) await hardhat.mine() }) setupInputs(USDT, USDC_MAINNET) cy.get('#swap-currency-input .token-amount-input').clear().type('1') initiateSwap() // Verify transaction cy.wait('@eth_sendRawTransaction') cy.hardhat().then((hardhat) => hardhat.mine()) cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') }) }) it('swaps when user has already approved token and permit2', () => { cy.hardhat().then(({ approval, wallet }) => Promise.all([ approval.setTokenAllowanceForPermit2({ owner: wallet, token: DAI }), approval.setPermit2Allowance({ owner: wallet, token: DAI }), ]) ) setupInputs(DAI, USDC_MAINNET) initiateSwap() // Verify transaction cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') }) it('swaps after handling user rejection of both approval and signature', () => { setupInputs(DAI, USDC_MAINNET) const USER_REJECTION = { code: 4001 } cy.hardhat().then((hardhat) => { // Reject token approval const tokenApprovalStub = cy.stub(hardhat.wallet, 'sendTransaction').log(false) tokenApprovalStub.rejects(USER_REJECTION) // rejects token approval initiateSwap() // Verify token approval rejection cy.wrap(tokenApprovalStub).should('be.calledOnce') cy.contains('Review swap') // Allow token approval cy.then(() => tokenApprovalStub.restore()) // Reject permit2 approval const permitApprovalStub = cy.stub(hardhat.provider, 'send').log(false) permitApprovalStub.withArgs('eth_signTypedData_v4').rejects(USER_REJECTION) // rejects permit approval permitApprovalStub.callThrough() // allows non-eth_signTypedData_v4 send calls to return non-stubbed values cy.contains('Confirm swap').click() // Verify token approval cy.get(getTestSelector('popups')).contains('Approved') expectTokenAllowanceForPermit2ToBeMax(DAI) // Verify permit2 approval rejection cy.wrap(permitApprovalStub).should('be.calledWith', 'eth_signTypedData_v4') cy.contains('Review swap') // Allow permit2 approval cy.then(() => permitApprovalStub.restore()) cy.contains('Confirm swap').click() // Verify permit2 approval cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') expectPermit2AllowanceForUniversalRouterToBeMax(DAI) }) }) it('prompts token approval when existing approval amount is too low', () => { setupInputs(DAI, USDC_MAINNET) cy.hardhat().then(({ approval, wallet }) => Promise.all([ approval.setPermit2Allowance({ owner: wallet, token: DAI }), approval.setTokenAllowanceForPermit2({ owner: wallet, token: DAI }, 1), ]) ) initiateSwap() // Verify token approval cy.get(getTestSelector('popups')).contains('Approved') expectPermit2AllowanceForUniversalRouterToBeMax(DAI) }) it('prompts signature when existing permit approval is expired', () => { setupInputs(DAI, USDC_MAINNET) const expiredAllowance = { expiration: Math.floor((Date.now() - 1) / 1000) } cy.hardhat().then(({ approval, wallet }) => Promise.all([ approval.setTokenAllowanceForPermit2({ owner: wallet, token: DAI }), approval.setPermit2Allowance({ owner: wallet, token: DAI }, expiredAllowance), ]) ) initiateSwap() // Verify permit2 approval cy.wait('@eth_signTypedData_v4') cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') expectPermit2AllowanceForUniversalRouterToBeMax(DAI) }) it('prompts signature when existing permit approval amount is too low', () => { setupInputs(DAI, USDC_MAINNET) const smallAllowance = { amount: 1 } cy.hardhat().then(({ approval, wallet }) => Promise.all([ approval.setTokenAllowanceForPermit2({ owner: wallet, token: DAI }), approval.setPermit2Allowance({ owner: wallet, token: DAI }, smallAllowance), ]) ) initiateSwap() // Verify permit2 approval cy.wait('@eth_signTypedData_v4') cy.contains('Swap success!') cy.get(getTestSelector('popups')).contains('Swapped') expectPermit2AllowanceForUniversalRouterToBeMax(DAI) }) })