diff --git a/beamer/tests/contracts/test_fee_sub.py b/beamer/tests/contracts/test_fee_sub.py index 7190f4657..5a3d47fea 100644 --- a/beamer/tests/contracts/test_fee_sub.py +++ b/beamer/tests/contracts/test_fee_sub.py @@ -151,6 +151,40 @@ def test_withdrawn_from_request_manager(fee_sub, token, request_manager): assert fee_sub.senders(request_id) == ADDRESS_ZERO +def test_amount_can_be_subsidized(fee_sub, token, request_manager): + transfer_amount = 95_000_000 + token_address = token.address + target_chain_id = ape.chain.chain_id + + # token subsidy is not activated for the token address + fee_sub.setMinimumAmount(token.address, 0) + can_be_subsidized = fee_sub.tokenAmountCanBeSubsidized( + target_chain_id, token_address, transfer_amount + ) + assert can_be_subsidized is False + + # transfer_amount is lower than the defined minimumAmount threshold + fee_sub.setMinimumAmount(token.address, 95_000_001) + can_be_subsidized = fee_sub.tokenAmountCanBeSubsidized( + target_chain_id, token_address, transfer_amount + ) + assert can_be_subsidized is False + + # fee amount is higher than the contracts token balance + fee_sub.setMinimumAmount(token.address, 95_000_000) + request_manager.updateFees(300_000, 15_000, 14_000) + can_be_subsidized = fee_sub.tokenAmountCanBeSubsidized( + target_chain_id, token_address, transfer_amount + ) + assert can_be_subsidized is False + + token.transfer(fee_sub.address, 100_000_000) + can_be_subsidized = fee_sub.tokenAmountCanBeSubsidized( + target_chain_id, token_address, transfer_amount + ) + assert can_be_subsidized is True + + def test_enable_disable_token(fee_sub, deployer, request_manager): token2 = deployer.deploy(ape.project.MintableToken, request_manager.address) fee_sub.setMinimumAmount(token2.address, 5) diff --git a/contracts/contracts/FeeSub.sol b/contracts/contracts/FeeSub.sol index 7e3f6c079..61b52119f 100644 --- a/contracts/contracts/FeeSub.sol +++ b/contracts/contracts/FeeSub.sol @@ -100,6 +100,31 @@ contract FeeSub is Ownable { token.safeTransfer(recipient, amount); } + function tokenAmountCanBeSubsidized( + uint256 targetChainId, + address tokenAddress, + uint256 amount + ) public view returns (bool) { + uint256 minimumAmount = minimumAmounts[tokenAddress]; + + if (minimumAmount == 0 || minimumAmount > amount) { + return false; + } + + uint256 tokenBalance = IERC20(tokenAddress).balanceOf(address(this)); + uint256 totalFee = requestManager.totalFee( + targetChainId, + tokenAddress, + amount + ); + + if (tokenBalance < totalFee) { + return false; + } + + return true; + } + function setMinimumAmount( address tokenAddress, uint256 amount diff --git a/frontend/config/chains/chain.ts b/frontend/config/chains/chain.ts index b682d667f..125436df5 100644 --- a/frontend/config/chains/chain.ts +++ b/frontend/config/chains/chain.ts @@ -9,10 +9,11 @@ export class ChainMetadata { readonly explorerUrl: string; readonly rpcUrl: string; readonly name: string; - readonly imageUrl?: string; readonly tokenSymbols: Array; - readonly nativeCurrency?: NativeCurrency; readonly internalRpcUrl: string; + readonly feeSubAddress?: string; + readonly nativeCurrency?: NativeCurrency; + readonly imageUrl?: string; readonly disabled?: boolean; readonly disabled_reason?: string; readonly hidden?: boolean; @@ -26,6 +27,7 @@ export class ChainMetadata { this.tokenSymbols = data.tokenSymbols ?? []; this.nativeCurrency = data.nativeCurrency; this.internalRpcUrl = data.internalRpcUrl; + this.feeSubAddress = data.feeSubAddress; this.disabled = data.disabled; this.disabled_reason = data.disabled_reason; this.hidden = data.hidden; @@ -65,6 +67,7 @@ export type ChainMetadataData = { tokenSymbols: Array; internalRpcUrl: string; nativeCurrency?: NativeCurrency; + feeSubAddress?: string; imageUrl?: string; disabled?: boolean; disabled_reason?: string; diff --git a/frontend/config/schema.ts b/frontend/config/schema.ts index 133f058a2..7e10ac1c4 100644 --- a/frontend/config/schema.ts +++ b/frontend/config/schema.ts @@ -100,6 +100,12 @@ export const configSchema: JSONSchemaType = { minLength: 42, maxLength: 42, }, + feeSubAddress: { + type: 'string', + minLength: 42, + maxLength: 42, + nullable: true, + }, disabled: { type: 'boolean', nullable: true, diff --git a/frontend/package.json b/frontend/package.json index 2f51478e6..0d09fe52f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,9 @@ "build": "yarn configure && vite build", "preview": "vite preview", "configure": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\", \"target\": \"es2021\"}' ts-node ./config/configure.ts && npx prettier --write config/**/*.json", - "generate-types": "typechain --target=ethers-v5 --glob='./node_modules/@beamer-bridge/deployments/dist/abis/**/!(deployment).json' --out-dir=./src/types/ethers-contracts/", + "generate-types": "yarn generate-beamer-types && yarn generate-external-types", + "generate-beamer-types": "typechain --target=ethers-v5 --glob='./node_modules/@beamer-bridge/deployments/dist/abis/**/!(deployment).json' --out-dir=./src/types/ethers-contracts/ ", + "generate-external-types": "typechain --target=ethers-v5 --glob='./src/assets/abi/**/*.json' --out-dir=./src/types/ethers-contracts/", "check-types": "tsc --noEmit", "eslint": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --max-warnings 0 .", "eslint:fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix .", diff --git a/frontend/src/actions/transfers/index.ts b/frontend/src/actions/transfers/index.ts index 9db41a2eb..74c516d17 100644 --- a/frontend/src/actions/transfers/index.ts +++ b/frontend/src/actions/transfers/index.ts @@ -1,5 +1,6 @@ export * from './allowance-information'; export * from './request-fulfillment'; export * from './request-information'; +export * from './subsidized-transfer'; export * from './transaction-information'; export * from './transfer'; diff --git a/frontend/src/actions/transfers/subsidized-transfer.ts b/frontend/src/actions/transfers/subsidized-transfer.ts new file mode 100644 index 000000000..7dac3ec02 --- /dev/null +++ b/frontend/src/actions/transfers/subsidized-transfer.ts @@ -0,0 +1,49 @@ +import type { IEthereumProvider } from '@/services/web3-provider'; +import { UInt256 } from '@/types/uint-256'; + +import { type TransferData, Transfer } from './transfer'; + +export class SubsidizedTransfer extends Transfer { + readonly feeSubAddress: string; + readonly originalRequestManagerAddress: string; + + constructor(data: SubsidizedTransferData) { + if (!data.sourceChain.feeSubAddress) { + throw new Error('Please provide a fee subsidy contract address.'); + } + super(data); + this.originalRequestManagerAddress = data.sourceChain.requestManagerAddress; + this.feeSubAddress = data.sourceChain.feeSubAddress; + Object.assign(this.fees.uint256, this.fees.uint256.multiply(new UInt256(0))); + } + + async ensureTokenAllowance(provider: IEthereumProvider): Promise { + if (this.feeSubAddress) { + this.sourceChain.requestManagerAddress = this.feeSubAddress; + } + await super.ensureTokenAllowance(provider); + this.sourceChain.requestManagerAddress = this.originalRequestManagerAddress; + } + + async sendRequestTransaction(provider: IEthereumProvider): Promise { + if (this.feeSubAddress) { + this.sourceChain.requestManagerAddress = this.feeSubAddress; + } + await super.sendRequestTransaction(provider); + this.sourceChain.requestManagerAddress = this.originalRequestManagerAddress; + } + + public encode(): SubsidizedTransferData { + const encodedTransferData = super.encode(); + return { + ...encodedTransferData, + feeSubAddress: this.feeSubAddress, + originalRequestManagerAddress: this.originalRequestManagerAddress, + }; + } +} + +export type SubsidizedTransferData = TransferData & { + feeSubAddress: string; + originalRequestManagerAddress: string; +}; diff --git a/frontend/src/actions/transfers/types.ts b/frontend/src/actions/transfers/types.ts new file mode 100644 index 000000000..7d92566e5 --- /dev/null +++ b/frontend/src/actions/transfers/types.ts @@ -0,0 +1,3 @@ +import type { SubsidizedTransferData, TransferData } from '.'; + +export type ExtendedTransferData = SubsidizedTransferData & TransferData; diff --git a/frontend/src/assets/abi/FeeSub.json b/frontend/src/assets/abi/FeeSub.json new file mode 100644 index 000000000..2cb69bc3c --- /dev/null +++ b/frontend/src/assets/abi/FeeSub.json @@ -0,0 +1,100 @@ +[ + { + "inputs": [{ "internalType": "address", "name": "_requestManager", "type": "address" }], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, + { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "targetChainId", "type": "uint256" }, + { "internalType": "address", "name": "sourceTokenAddress", "type": "address" }, + { "internalType": "address", "name": "targetTokenAddress", "type": "address" }, + { "internalType": "address", "name": "targetAddress", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" }, + { "internalType": "uint256", "name": "validityPeriod", "type": "uint256" } + ], + "name": "createRequest", + "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "token", "type": "address" }], + "name": "minimumAmounts", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "requestManager", + "outputs": [{ "internalType": "contract RequestManager", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "requestId", "type": "bytes32" }], + "name": "senders", + "outputs": [{ "internalType": "address", "name": "sender", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "tokenAddress", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "setMinimumAmount", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "targetChainId", "type": "uint256" }, + { "internalType": "address", "name": "tokenAddress", "type": "address" }, + { "internalType": "uint256", "name": "amount", "type": "uint256" } + ], + "name": "tokenAmountCanBeSubsidized", + "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "bytes32", "name": "requestId", "type": "bytes32" }], + "name": "withdrawExpiredRequest", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/frontend/src/components/RequestSourceInputs.vue b/frontend/src/components/RequestSourceInputs.vue index a485a94cc..c677af4ee 100644 --- a/frontend/src/components/RequestSourceInputs.vue +++ b/frontend/src/components/RequestSourceInputs.vue @@ -239,6 +239,7 @@ const { amount: requestFeeAmount, loading: requestFeeLoading } = useRequestFee( computed(() => selectedSourceChain.value?.value.internalRpcUrl), computed(() => selectedSourceChain.value?.value.requestManagerAddress), selectedTokenAmount, + computed(() => selectedSourceChain.value?.value), computed(() => props.targetChain?.value), true, ); diff --git a/frontend/src/composables/useChainSelection.ts b/frontend/src/composables/useChainSelection.ts index 7829ad7c7..b6fb5ce0c 100644 --- a/frontend/src/composables/useChainSelection.ts +++ b/frontend/src/composables/useChainSelection.ts @@ -35,6 +35,7 @@ export function getChainSelectorOption( imageUrl: chains[chainId].imageUrl, nativeCurrency: chains[chainId].nativeCurrency, internalRpcUrl: chains[chainId].internalRpcUrl, + feeSubAddress: chains[chainId].feeSubAddress, disabled: chains[chainId].disabled, disabled_reason: chains[chainId].disabled_reason, hidden: chains[chainId].hidden, diff --git a/frontend/src/composables/useMaxTransferableTokenAmount.ts b/frontend/src/composables/useMaxTransferableTokenAmount.ts index 0649e1ede..cbe18e1e2 100644 --- a/frontend/src/composables/useMaxTransferableTokenAmount.ts +++ b/frontend/src/composables/useMaxTransferableTokenAmount.ts @@ -1,6 +1,7 @@ import type { Ref } from 'vue'; import { ref, watch } from 'vue'; +import { amountCanBeSubsidized } from '@/services/transactions/fee-sub'; import { getAmountBeforeFees } from '@/services/transactions/request-manager'; import type { Chain } from '@/types/data'; import { TokenAmount } from '@/types/token-amount'; @@ -19,12 +20,24 @@ export function useMaxTransferableTokenAmount( targetChain: Chain, ) { try { - const transferableAmount = await getAmountBeforeFees( + const canBeSubsidized = await amountCanBeSubsidized( + sourceChain, + targetChain, + balance.token, balance, - sourceChain.internalRpcUrl, - sourceChain.requestManagerAddress, - targetChain.identifier, ); + + let transferableAmount; + if (canBeSubsidized) { + transferableAmount = balance.uint256; + } else { + transferableAmount = await getAmountBeforeFees( + balance, + sourceChain.internalRpcUrl, + sourceChain.requestManagerAddress, + targetChain.identifier, + ); + } maxTransferableTokenAmount.value = TokenAmount.new(transferableAmount, balance.token); } catch (e) { maxTransferableTokenAmount.value = undefined; diff --git a/frontend/src/composables/useRequestFee.ts b/frontend/src/composables/useRequestFee.ts index 462151d77..f5c8165d7 100644 --- a/frontend/src/composables/useRequestFee.ts +++ b/frontend/src/composables/useRequestFee.ts @@ -2,14 +2,17 @@ import type { Ref } from 'vue'; import { ref, watch } from 'vue'; import { useDebouncedTask } from '@/composables/useDebouncedTask'; +import { amountCanBeSubsidized } from '@/services/transactions/fee-sub'; import { getRequestFee } from '@/services/transactions/request-manager'; import type { Chain } from '@/types/data'; import { TokenAmount } from '@/types/token-amount'; +import { UInt256 } from '@/types/uint-256'; export function useRequestFee( rpcUrl: Ref, requestManagerAddress: Ref, requestAmount: Ref, + sourceChain: Ref, targetChain: Ref, debounced?: boolean, debouncedDelay = 500, @@ -26,7 +29,8 @@ export function useRequestFee( !rpcUrl.value || !requestManagerAddress.value || !requestAmount.value || - !targetChain.value + !targetChain.value || + !sourceChain.value ) { amount.value = undefined; loading.value = false; @@ -34,12 +38,23 @@ export function useRequestFee( } try { - const requestFee = await getRequestFee( - rpcUrl.value, - requestManagerAddress.value, + const canBeSubsdized = await amountCanBeSubsidized( + sourceChain.value, + targetChain.value, + requestAmount.value.token, requestAmount.value, - targetChain.value.identifier, ); + let requestFee; + if (canBeSubsdized) { + requestFee = new UInt256(0); + } else { + requestFee = await getRequestFee( + rpcUrl.value, + requestManagerAddress.value, + requestAmount.value, + targetChain.value.identifier, + ); + } amount.value = TokenAmount.new(requestFee, requestAmount.value.token); } catch (exception: unknown) { const errorMessage = (exception as { message?: string }).message; diff --git a/frontend/src/composables/useTokenAllowance.ts b/frontend/src/composables/useTokenAllowance.ts index 4f407ce35..7168c3785 100644 --- a/frontend/src/composables/useTokenAllowance.ts +++ b/frontend/src/composables/useTokenAllowance.ts @@ -23,7 +23,7 @@ export function useTokenAllowance( provider.value, token.value, signerAddress.value, - sourceChain.value.requestManagerAddress, + sourceChain.value.feeSubAddress || sourceChain.value.requestManagerAddress, ); } else { allowance.value = undefined; diff --git a/frontend/src/composables/useTransferRequest.ts b/frontend/src/composables/useTransferRequest.ts index 0f88ffdf5..71c99340f 100644 --- a/frontend/src/composables/useTransferRequest.ts +++ b/frontend/src/composables/useTransferRequest.ts @@ -1,7 +1,9 @@ import { reactive } from 'vue'; import { Transfer } from '@/actions/transfers'; +import { SubsidizedTransfer } from '@/actions/transfers/subsidized-transfer'; import { useAsynchronousTask } from '@/composables/useAsynchronousTask'; +import { amountCanBeSubsidized } from '@/services/transactions/fee-sub'; import { getRequestFee } from '@/services/transactions/request-manager'; import type { Eip1193Provider, @@ -39,21 +41,34 @@ export function useTransferRequest() { ); const fees = TokenAmount.new(requestFee, sourceTokenAmount.token); - const transfer = reactive( - Transfer.new( + const transferData = [ + options.sourceChain, + sourceTokenAmount, + options.targetChain, + targetTokenAmount, + options.toAddress, + validityPeriod, + fees, + options.approveInfiniteAmount, + options.requestCreatorAddress, + ] as const; + + let transfer; + if ( + options.sourceChain.feeSubAddress && + (await amountCanBeSubsidized( options.sourceChain, - sourceTokenAmount, options.targetChain, - targetTokenAmount, - options.toAddress, - validityPeriod, - fees, - options.approveInfiniteAmount, - options.requestCreatorAddress, - ), - ) as Transfer; + options.sourceToken, + sourceTokenAmount, + )) + ) { + transfer = SubsidizedTransfer.new(...transferData); + } else { + transfer = Transfer.new(...transferData); + } - return transfer; + return reactive(transfer) as Transfer; }; const execute = async (provider: IEthereumProvider, transfer: Transfer): Promise => { diff --git a/frontend/src/services/transactions/fee-sub.ts b/frontend/src/services/transactions/fee-sub.ts new file mode 100644 index 000000000..1f810f1b5 --- /dev/null +++ b/frontend/src/services/transactions/fee-sub.ts @@ -0,0 +1,31 @@ +import { getJsonRpcProvider, getReadOnlyContract } from '@/services/transactions/utils'; +import type { Chain, Token } from '@/types/data'; +import type { FeeSub } from '@/types/ethers-contracts'; +import { FeeSub__factory } from '@/types/ethers-contracts'; +import type { TokenAmount } from '@/types/token-amount'; + +export async function amountCanBeSubsidized( + sourceChain: Chain, + targetChain: Chain, + token: Token, + tokenAmount: TokenAmount, +): Promise { + if (!sourceChain.feeSubAddress) { + return false; + } + const provider = getJsonRpcProvider(sourceChain.internalRpcUrl); + + const feeSubContract = getReadOnlyContract( + sourceChain.feeSubAddress, + FeeSub__factory.createInterface(), + provider, + ); + + const canBeSubsidized = await feeSubContract.tokenAmountCanBeSubsidized( + targetChain.identifier, + token.address, + tokenAmount.uint256.asBigNumber, + ); + + return canBeSubsidized; +} diff --git a/frontend/src/services/transactions/fill-manager.ts b/frontend/src/services/transactions/fill-manager.ts index efdae2a3f..6f6f6f60c 100644 --- a/frontend/src/services/transactions/fill-manager.ts +++ b/frontend/src/services/transactions/fill-manager.ts @@ -5,9 +5,8 @@ import { getSafeEventHandler, } from '@/services/transactions/utils'; import type { Cancelable } from '@/types/async'; -import type { FillManager } from '@/types/ethers-contracts'; import { FillManager__factory } from '#contract-factories/FillManager__factory.ts'; -import type { RequestFilledEvent } from '#contracts/FillManager.ts'; +import type { FillManager, RequestFilledEvent } from '#contracts/FillManager.ts'; import { fetchFirstMatchingEvent } from '../events/filter-utils'; diff --git a/frontend/src/services/transactions/request-manager.ts b/frontend/src/services/transactions/request-manager.ts index 4dff7d0ec..354cb4a2d 100644 --- a/frontend/src/services/transactions/request-manager.ts +++ b/frontend/src/services/transactions/request-manager.ts @@ -2,10 +2,10 @@ import type { Listener, TransactionResponse } from '@ethersproject/providers'; import type { Cancelable } from '@/types/async'; import type { EthereumAddress, TransactionHash } from '@/types/data'; -import type { RequestManager } from '@/types/ethers-contracts'; import type { TokenAmount } from '@/types/token-amount'; import { UInt256 } from '@/types/uint-256'; import { RequestManager__factory } from '#contract-factories/RequestManager__factory.ts'; +import type { RequestManager } from '#contracts/RequestManager.ts'; import type { IEthereumProvider } from '../web3-provider'; import { diff --git a/frontend/src/stores/transfer-history/serializer.ts b/frontend/src/stores/transfer-history/serializer.ts index 1c0f2889e..4e77afa0c 100644 --- a/frontend/src/stores/transfer-history/serializer.ts +++ b/frontend/src/stores/transfer-history/serializer.ts @@ -3,7 +3,8 @@ import type { Serializer } from 'pinia-plugin-persistedstate'; import type { StepData } from '@/actions/steps'; import type { TransferData } from '@/actions/transfers'; -import { Transfer } from '@/actions/transfers'; +import { SubsidizedTransfer, Transfer } from '@/actions/transfers'; +import type { ExtendedTransferData } from '@/actions/transfers/types'; import type { TransferHistoryState } from './types'; @@ -22,7 +23,13 @@ export const transferHistorySerializer: Serializer = { } else { const { transfers = [] } = encodedState; const inactiveTransfers = transfers.map(markTransferInactive); - state.transfers = inactiveTransfers.map((data: TransferData) => new Transfer(data)); + state.transfers = inactiveTransfers.map((data: ExtendedTransferData) => { + if (data.feeSubAddress) { + return new SubsidizedTransfer(data); + } else { + return new Transfer(data); + } + }); } state.loaded = true; diff --git a/frontend/src/types/data.ts b/frontend/src/types/data.ts index efa77e523..0f722b131 100644 --- a/frontend/src/types/data.ts +++ b/frontend/src/types/data.ts @@ -14,9 +14,10 @@ export type Chain = { requestManagerAddress: EthereumAddress; fillManagerAddress: EthereumAddress; explorerUrl: string; // TODO: restrict more + internalRpcUrl: string; + feeSubAddress?: string; imageUrl?: string; // TODO: restrict more nativeCurrency?: NativeCurrency; - internalRpcUrl: string; disabled?: boolean; disabled_reason?: string; hidden?: boolean; diff --git a/frontend/tests/unit/composables/useMaxTransferableTokenAmount.spec.ts b/frontend/tests/unit/composables/useMaxTransferableTokenAmount.spec.ts index f9e3b91c8..3a9e608ec 100644 --- a/frontend/tests/unit/composables/useMaxTransferableTokenAmount.spec.ts +++ b/frontend/tests/unit/composables/useMaxTransferableTokenAmount.spec.ts @@ -3,6 +3,7 @@ import type { Ref } from 'vue'; import { ref } from 'vue'; import { useMaxTransferableTokenAmount } from '@/composables/useMaxTransferableTokenAmount'; +import * as feeSubService from '@/services/transactions/fee-sub'; import * as requestManagerService from '@/services/transactions/request-manager'; import { TokenAmount } from '@/types/token-amount'; import { UInt256 } from '@/types/uint-256'; @@ -16,6 +17,7 @@ const TARGET_CHAIN_REF = ref(TARGET_CHAIN); const TOKEN_AMOUNT = ref(new TokenAmount({ amount: '1000', token: TOKEN })) as Ref; vi.mock('@/services/transactions/request-manager'); +vi.mock('@/services/transactions/fee-sub'); describe('useMaxTransferableTokenAmount', () => { beforeEach(() => { @@ -49,27 +51,52 @@ describe('useMaxTransferableTokenAmount', () => { expect(maxTransferableTokenAmount.value).toBeUndefined(); }); - it('holds the actual transferable amount derived from the provided total amount', async () => { - const totalAmount = ref( - new TokenAmount({ amount: '1000', token: TOKEN }), - ) as Ref; - - const mockedAmountBeforeFees = totalAmount.value.uint256.subtract(new UInt256('100')); - Object.defineProperty(requestManagerService, 'getAmountBeforeFees', { - value: vi.fn().mockReturnValue(mockedAmountBeforeFees), + describe('if transfer can be subsidized', () => { + it('holds the full token balance as a transferable amount', async () => { + const totalAmount = ref( + new TokenAmount({ amount: '1000', token: TOKEN }), + ) as Ref; + + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockReturnValue(true), + }); + + const { maxTransferableTokenAmount } = useMaxTransferableTokenAmount( + totalAmount, + SOURCE_CHAIN_REF, + TARGET_CHAIN_REF, + ); + await flushPromises(); + + expect(maxTransferableTokenAmount.value).not.toBeUndefined(); + expect(maxTransferableTokenAmount.value?.uint256.asString).toBe( + totalAmount.value.uint256.asString, + ); + }); + }); + describe('if transfer cannot be subsidized', () => { + it('holds the actual transferable amount derived from the provided total amount', async () => { + const totalAmount = ref( + new TokenAmount({ amount: '1000', token: TOKEN }), + ) as Ref; + + const mockedAmountBeforeFees = totalAmount.value.uint256.subtract(new UInt256('100')); + Object.defineProperty(requestManagerService, 'getAmountBeforeFees', { + value: vi.fn().mockReturnValue(mockedAmountBeforeFees), + }); + + const { maxTransferableTokenAmount } = useMaxTransferableTokenAmount( + totalAmount, + SOURCE_CHAIN_REF, + TARGET_CHAIN_REF, + ); + await flushPromises(); + + expect(maxTransferableTokenAmount.value).not.toBeUndefined(); + expect(maxTransferableTokenAmount.value?.uint256.asString).toBe( + mockedAmountBeforeFees.asString, + ); }); - - const { maxTransferableTokenAmount } = useMaxTransferableTokenAmount( - totalAmount, - SOURCE_CHAIN_REF, - TARGET_CHAIN_REF, - ); - await flushPromises(); - - expect(maxTransferableTokenAmount.value).not.toBeUndefined(); - expect(maxTransferableTokenAmount.value?.uint256.asString).toBe( - mockedAmountBeforeFees.asString, - ); }); it('is undefined when calculation fails with an exception', async () => { diff --git a/frontend/tests/unit/composables/useRequestFee.spec.ts b/frontend/tests/unit/composables/useRequestFee.spec.ts index 29033db06..3bf3b22d3 100644 --- a/frontend/tests/unit/composables/useRequestFee.spec.ts +++ b/frontend/tests/unit/composables/useRequestFee.spec.ts @@ -3,6 +3,7 @@ import type { Ref } from 'vue'; import { ref } from 'vue'; import { useRequestFee } from '@/composables/useRequestFee'; +import * as feeSubService from '@/services/transactions/fee-sub'; import * as requestManagerService from '@/services/transactions/request-manager'; import { TokenAmount } from '@/types/token-amount'; import { UInt256 } from '@/types/uint-256'; @@ -14,17 +15,21 @@ import { } from '~/utils/data_generators'; vi.mock('@/services/transactions/request-manager'); +vi.mock('@/services/transactions/fee-sub'); const RPC_URL = ref('https://test.rpc'); const REQUEST_MANAGER_ADDRESS = ref(getRandomEthereumAddress()); const REQUEST_AMOUNT = ref(new TokenAmount(generateTokenAmountData())) as Ref; const TARGET_CHAIN = ref(generateChain()); +const SOURCE_CHAIN = ref(generateChain()); describe('useRequestFee', () => { beforeEach(() => { Object.defineProperty(requestManagerService, 'getRequestFee', { value: vi.fn().mockResolvedValue(new UInt256(generateUInt256Data())), }); - + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockResolvedValue(false), + }); global.console.error = vi.fn(); }); @@ -34,6 +39,7 @@ describe('useRequestFee', () => { ref(undefined), REQUEST_MANAGER_ADDRESS, REQUEST_AMOUNT, + SOURCE_CHAIN, TARGET_CHAIN, ); @@ -41,7 +47,13 @@ describe('useRequestFee', () => { }); it('should be undefined if the request manager address is missing', () => { - const { amount } = useRequestFee(RPC_URL, ref(undefined), REQUEST_AMOUNT, TARGET_CHAIN); + const { amount } = useRequestFee( + RPC_URL, + ref(undefined), + REQUEST_AMOUNT, + SOURCE_CHAIN, + TARGET_CHAIN, + ); expect(amount.value).toBeUndefined(); }); @@ -51,40 +63,81 @@ describe('useRequestFee', () => { RPC_URL, REQUEST_MANAGER_ADDRESS, ref(undefined), + SOURCE_CHAIN, TARGET_CHAIN, ); expect(amount.value).toBeUndefined(); }); - it('should be undefined if the target chain is missing', () => { + it('should be undefined if the source chain is missing', () => { const { amount } = useRequestFee( RPC_URL, REQUEST_MANAGER_ADDRESS, REQUEST_AMOUNT, ref(undefined), + TARGET_CHAIN, ); expect(amount.value).toBeUndefined(); }); - it('should be fetched from request manager', async () => { - Object.defineProperty(requestManagerService, 'getRequestFee', { - value: vi.fn().mockResolvedValue(new UInt256('99999')), - }); - const requestAmount = ref(new TokenAmount(generateTokenAmountData())) as Ref; - + it('should be undefined if the target chain is missing', () => { const { amount } = useRequestFee( RPC_URL, REQUEST_MANAGER_ADDRESS, - requestAmount, - TARGET_CHAIN, + REQUEST_AMOUNT, + SOURCE_CHAIN, + ref(undefined), ); - await flushPromises(); - expect(amount.value).toEqual( - new TokenAmount({ token: requestAmount.value.token, amount: '99999' }), - ); + expect(amount.value).toBeUndefined(); + }); + + describe('if transfer can be subsidized', () => { + it('fees should be zero', async () => { + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockResolvedValue(true), + }); + const requestAmount = ref(new TokenAmount(generateTokenAmountData())) as Ref; + + const { amount } = useRequestFee( + RPC_URL, + REQUEST_MANAGER_ADDRESS, + requestAmount, + SOURCE_CHAIN, + TARGET_CHAIN, + ); + await flushPromises(); + + expect(amount.value).toEqual( + new TokenAmount({ token: requestAmount.value.token, amount: '0' }), + ); + }); + }); + describe('if transfer cannot be subsidized', () => { + it('should be fetched from request manager', async () => { + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockResolvedValue(false), + }); + Object.defineProperty(requestManagerService, 'getRequestFee', { + value: vi.fn().mockResolvedValue(new UInt256('99999')), + }); + const requestAmount = ref(new TokenAmount(generateTokenAmountData())) as Ref; + + const { amount } = useRequestFee( + RPC_URL, + REQUEST_MANAGER_ADDRESS, + requestAmount, + SOURCE_CHAIN, + TARGET_CHAIN, + ); + await flushPromises(); + + expect(amount.value).toEqual( + new TokenAmount({ token: requestAmount.value.token, amount: '99999' }), + ); + }); }); it('should update itself when the request amount changes', async () => { @@ -94,6 +147,7 @@ describe('useRequestFee', () => { RPC_URL, REQUEST_MANAGER_ADDRESS, requestAmount, + SOURCE_CHAIN, TARGET_CHAIN, ); await flushPromises(); @@ -120,6 +174,7 @@ describe('useRequestFee', () => { RPC_URL, REQUEST_MANAGER_ADDRESS, REQUEST_AMOUNT, + SOURCE_CHAIN, TARGET_CHAIN, ); await flushPromises(); @@ -132,6 +187,7 @@ describe('useRequestFee', () => { RPC_URL, REQUEST_MANAGER_ADDRESS, REQUEST_AMOUNT, + SOURCE_CHAIN, TARGET_CHAIN, ); await flushPromises(); @@ -147,6 +203,7 @@ describe('useRequestFee', () => { REQUEST_MANAGER_ADDRESS, REQUEST_AMOUNT, TARGET_CHAIN, + SOURCE_CHAIN, true, delayInMillis, ); diff --git a/frontend/tests/unit/composables/useTokenAllowance.spec.ts b/frontend/tests/unit/composables/useTokenAllowance.spec.ts index 46a2a0552..d5289f059 100644 --- a/frontend/tests/unit/composables/useTokenAllowance.spec.ts +++ b/frontend/tests/unit/composables/useTokenAllowance.spec.ts @@ -90,4 +90,31 @@ describe('useTokenAllowance', () => { expect(allowanceBelowMax.value).toBe(false); }); }); + + describe('if a fee subsidy contract is defined', () => { + it('checks the allowance for the fee sub contract', () => { + const sourceChain = ref(generateChain({ feeSubAddress: '0x123' })); + useTokenAllowance(PROVIDER, TOKEN, sourceChain); + + expect(tokenService.getTokenAllowance).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(String), + sourceChain.value.feeSubAddress, + ); + }); + }); + describe('if a fee subsidy contract is not defined', () => { + it('checks the allowance for the request manager contract', () => { + const sourceChain = ref(generateChain({ feeSubAddress: undefined })); + useTokenAllowance(PROVIDER, TOKEN, sourceChain); + + expect(tokenService.getTokenAllowance).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(String), + sourceChain.value.requestManagerAddress, + ); + }); + }); }); diff --git a/frontend/tests/unit/composables/useTransferRequest.spec.ts b/frontend/tests/unit/composables/useTransferRequest.spec.ts index 38145adbb..42c652ccb 100644 --- a/frontend/tests/unit/composables/useTransferRequest.spec.ts +++ b/frontend/tests/unit/composables/useTransferRequest.spec.ts @@ -1,8 +1,11 @@ import { JsonRpcProvider, JsonRpcSigner } from '@ethersproject/providers'; import { Transfer } from '@/actions/transfers'; +import { SubsidizedTransfer } from '@/actions/transfers/subsidized-transfer'; import { useTransferRequest } from '@/composables/useTransferRequest'; +import * as feeSubService from '@/services/transactions/fee-sub'; import * as requestManagerService from '@/services/transactions/request-manager'; +import type { Chain, EthereumAddress, Token } from '@/types/data'; import { TokenAmount } from '@/types/token-amount'; import { UInt256 } from '@/types/uint-256'; import { @@ -16,11 +19,45 @@ import { MockedEthereumProvider } from '~/utils/mocks/ethereum-provider'; vi.mock('@ethersproject/providers'); vi.mock('@/services/transactions/request-manager'); +vi.mock('@/service/transactions/fee-sub'); const SIGNER = new JsonRpcSigner(undefined, new JsonRpcProvider()); const SIGNER_ADDRESS = '0xSigner'; const PROVIDER = new MockedEthereumProvider({ signer: SIGNER, signerAddress: SIGNER_ADDRESS }); +function createConfig(options?: { + sourceChain?: Chain; + targetChain?: Chain; + sourceAmount?: string; + targetAmount?: string; + toAddress?: EthereumAddress; + sourceToken?: Token; + targetToken?: Token; + approveInfiniteAmount?: boolean; + requestCreatorAddress?: EthereumAddress; +}) { + const sourceToken = options?.sourceToken ?? generateToken(); + const targetToken = options?.targetToken ?? generateToken(); + const sourceAmount = options?.sourceAmount ?? '123'; + const targetAmount = options?.targetAmount ?? '123'; + const sourceTokenAmount = TokenAmount.parse(sourceAmount, sourceToken); + const targetTokenAmount = TokenAmount.parse(targetAmount, targetToken); + + return { + sourceToken, + targetToken, + sourceAmount, + targetAmount, + sourceTokenAmount, + targetTokenAmount, + sourceChain: options?.sourceChain ?? generateChain(), + targetChain: options?.targetChain ?? generateChain(), + toAddress: options?.toAddress ?? getRandomEthereumAddress(), + approveInfiniteAmount: options?.approveInfiniteAmount ?? false, + requestCreatorAddress: options?.requestCreatorAddress ?? getRandomEthereumAddress(), + }; +} + describe('useTransferRequest', () => { let generatedFee: UInt256; @@ -33,14 +70,17 @@ describe('useTransferRequest', () => { describe('create()', () => { it('creates and returns a new transfer object', async () => { - const sourceChain = generateChain(); - const sourceAmount = '123'; - const targetAmount = '123'; - const targetChain = generateChain(); - const toAddress = getRandomEthereumAddress(); - const sourceToken = generateToken(); - const targetToken = generateToken(); - const requestCreatorAddress = getRandomEthereumAddress(); + const { + sourceChain, + targetChain, + sourceAmount, + sourceTokenAmount, + targetTokenAmount, + toAddress, + sourceToken, + targetToken, + requestCreatorAddress, + } = createConfig(); const { create } = useTransferRequest(); const transfer: Transfer = await create({ @@ -54,8 +94,6 @@ describe('useTransferRequest', () => { requestCreatorAddress, }); - const sourceTokenAmount = TokenAmount.parse(sourceAmount, sourceToken); - const targetTokenAmount = TokenAmount.parse(targetAmount, targetToken); const feeAmount = TokenAmount.new(generatedFee, sourceToken); expect(requestManagerService.getRequestFee).toHaveBeenCalledOnce(); expect(requestManagerService.getRequestFee).toHaveBeenCalledWith( @@ -75,6 +113,70 @@ describe('useTransferRequest', () => { expect(transfer.approveInfiniteAmount).toBe(true); expect(transfer.requestInformation?.requestAccount).toBe(requestCreatorAddress); }); + + describe('if transfer can be subsidized', () => { + it('creates and returns an instance of a SubsidizedTransfer class', async () => { + const sourceChain = generateChain({ feeSubAddress: '0x123' }); + + const { + targetChain, + sourceAmount, + toAddress, + sourceToken, + targetToken, + requestCreatorAddress, + } = createConfig({ sourceChain }); + + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockResolvedValue(true), + }); + + const { create } = useTransferRequest(); + const subsidizedTransfer = await create({ + sourceChain, + sourceAmount, + targetChain, + toAddress, + sourceToken, + targetToken, + approveInfiniteAmount: true, + requestCreatorAddress, + }); + + expect(subsidizedTransfer).toBeInstanceOf(SubsidizedTransfer); + }); + }); + describe('if transfer cannot be subsidized', () => { + it('creates and returns an instance of a Transfer class', async () => { + const { + sourceChain, + targetChain, + sourceAmount, + toAddress, + sourceToken, + targetToken, + requestCreatorAddress, + } = createConfig(); + + Object.defineProperty(feeSubService, 'amountCanBeSubsidized', { + value: vi.fn().mockResolvedValue(false), + }); + + const { create } = useTransferRequest(); + const subsidizedTransfer = await create({ + sourceChain, + sourceAmount, + targetChain, + toAddress, + sourceToken, + targetToken, + approveInfiniteAmount: true, + requestCreatorAddress, + }); + + expect(subsidizedTransfer).toBeInstanceOf(Transfer); + }); + }); }); describe('execute()', () => { diff --git a/frontend/tests/unit/services/transactions/fee-sub.spec.ts b/frontend/tests/unit/services/transactions/fee-sub.spec.ts new file mode 100644 index 000000000..af6fdf630 --- /dev/null +++ b/frontend/tests/unit/services/transactions/fee-sub.spec.ts @@ -0,0 +1,73 @@ +import { amountCanBeSubsidized } from '@/services/transactions/fee-sub'; +import type { Chain, Token } from '@/types/data'; +import { TokenAmount } from '@/types/token-amount'; +import { generateChain, generateToken } from '~/utils/data_generators'; +import { mockGetFeeSubContract } from '~/utils/mocks/services/transactions/utils'; + +vi.mock('@/services/transactions/utils'); +vi.mock('@ethersproject/providers'); + +function createConfig(options?: { + sourceChain?: Chain; + targetChain?: Chain; + token?: Token; + amount?: string; +}) { + const token = options?.token ?? generateToken(); + const amount = options?.amount ?? '123'; + + const tokenAmount = new TokenAmount({ amount, token }); + + return { + sourceChain: options?.sourceChain ?? generateChain(), + targetChain: options?.targetChain ?? generateChain(), + token, + tokenAmount, + }; +} + +describe('fee-sub', () => { + beforeEach(() => { + mockGetFeeSubContract(); + }); + describe('amountCanBeSubsidized', () => { + it('returns false when a feeSubAddress is not defined for the chain', async () => { + const { sourceChain, targetChain, token, tokenAmount } = createConfig({ + sourceChain: generateChain({ feeSubAddress: undefined }), + }); + + const canBeSubsidized = await amountCanBeSubsidized( + sourceChain, + targetChain, + token, + tokenAmount, + ); + expect(canBeSubsidized).toEqual(false); + }); + + it('returns whether an amount can be subsidized or not', async () => { + const { sourceChain, targetChain, token, tokenAmount } = createConfig({ + sourceChain: generateChain({ feeSubAddress: '0x123' }), + amount: '100', + }); + + const contract = mockGetFeeSubContract(); + contract.tokenAmountCanBeSubsidized = vi.fn().mockResolvedValue(true); + + const canBeSubsdized = await amountCanBeSubsidized( + sourceChain, + targetChain, + token, + tokenAmount, + ); + + expect(contract.tokenAmountCanBeSubsidized).toHaveBeenNthCalledWith( + 1, + targetChain.identifier, + token.address, + tokenAmount.uint256.asBigNumber, + ); + expect(canBeSubsdized).toEqual(true); + }); + }); +}); diff --git a/frontend/tests/unit/stores/transfer-history/serializer.spec.ts b/frontend/tests/unit/stores/transfer-history/serializer.spec.ts index 2a8a93f7d..0ba1431d3 100644 --- a/frontend/tests/unit/stores/transfer-history/serializer.spec.ts +++ b/frontend/tests/unit/stores/transfer-history/serializer.spec.ts @@ -1,9 +1,10 @@ -import { Transfer } from '@/actions/transfers'; +import { SubsidizedTransfer, Transfer } from '@/actions/transfers'; import { transferHistorySerializer } from '@/stores/transfer-history/serializer'; -import { generateStepData, generateTransferData } from '~/utils/data_generators'; +import { generateChain, generateStepData, generateTransferData } from '~/utils/data_generators'; vi.mock('@/actions/transfers', () => ({ Transfer: vi.fn().mockImplementation((data) => ({ data })), + SubsidizedTransfer: vi.fn().mockImplementation((data) => ({ data })), })); describe('transfer history serializer', () => { @@ -60,6 +61,23 @@ describe('transfer history serializer', () => { expect(Transfer).toHaveBeenCalledWith('transfer two data'); }); + it('is able to detect and create instances of subsidized and non-subsidized transfers accordingly', () => { + const subsidizedTransferData = generateTransferData({ + sourceChain: generateChain({ feeSubAddress: '0x123' }), + feeSubAddress: '0x123', + }); + const unsubsidizedTransferData = generateTransferData(); + + const state = transferHistorySerializer.deserialize( + JSON.stringify({ + transfers: [subsidizedTransferData, unsubsidizedTransferData], + }), + ); + + expect(state.transfers[0]).toBeInstanceOf(SubsidizedTransfer); + expect(state.transfers[1]).toBeInstanceOf(Transfer); + }); + it('returns filled state with transfers from parsed data', () => { const transferOneData = generateTransferData(); const transferTwoData = generateTransferData(); diff --git a/frontend/tests/utils/data_generators.ts b/frontend/tests/utils/data_generators.ts index 33171d4b2..85f61ff82 100644 --- a/frontend/tests/utils/data_generators.ts +++ b/frontend/tests/utils/data_generators.ts @@ -12,6 +12,7 @@ import type { } from '@/actions/transfers'; import { Transfer } from '@/actions/transfers'; import type { RequestFulfillmentData } from '@/actions/transfers/request-fulfillment'; +import type { ExtendedTransferData } from '@/actions/transfers/types'; import type { BeamerConfig, ChainWithTokens } from '@/types/config'; import type { Chain, EthereumAddress, Token, TransactionHash } from '@/types/data'; import type { SelectorOption } from '@/types/form'; @@ -151,7 +152,9 @@ export function generateAllowanceInformationData( }; } -export function generateTransferData(partialTransferData?: Partial): TransferData { +export function generateTransferData( + partialTransferData?: Partial, +): TransferData { return { sourceChain: generateChain(), sourceAmount: generateTokenAmountData(), diff --git a/frontend/tests/utils/mocks/beamer.ts b/frontend/tests/utils/mocks/beamer.ts index 388383f5b..bf81d0e70 100644 --- a/frontend/tests/utils/mocks/beamer.ts +++ b/frontend/tests/utils/mocks/beamer.ts @@ -58,3 +58,6 @@ export class MockedFillManagerContract { on = vi.fn(); removeAllListeners = vi.fn(); } +export class MockedFeeSubContract { + tokenAmountCanBeSubsidized = vi.fn(); +} diff --git a/frontend/tests/utils/mocks/services/transactions/utils.ts b/frontend/tests/utils/mocks/services/transactions/utils.ts index 6a69e4808..a0ab462b2 100644 --- a/frontend/tests/utils/mocks/services/transactions/utils.ts +++ b/frontend/tests/utils/mocks/services/transactions/utils.ts @@ -1,7 +1,11 @@ import { JsonRpcProvider } from '@ethersproject/providers'; import * as transactionUtils from '@/services/transactions/utils'; -import { MockedFillManagerContract, MockedRequestManagerContract } from '~/utils/mocks/beamer'; +import { + MockedFeeSubContract, + MockedFillManagerContract, + MockedRequestManagerContract, +} from '~/utils/mocks/beamer'; import { MockedERC20TokenContract } from '~/utils/mocks/ethers'; export function mockGetSafeEventHandler() { @@ -78,3 +82,17 @@ export function mockGetFillManagerContract() { return contract; } +export function mockGetFeeSubContract() { + const contract = new MockedFeeSubContract(); + + Object.defineProperties(transactionUtils, { + getReadOnlyContract: { + value: vi.fn().mockReturnValue(contract), + }, + getReadWriteContract: { + value: vi.fn().mockReturnValue(contract), + }, + }); + + return contract; +}