Nice question in terms of Account Abstractions (ERC-4337), though its primary aim is to allow dApp users to submit gasless transactions, dApp developers could also use it as a way to allow users to submit transactions on behalf of the dApp.
Before delving into the specifics of Account Abstractions, there are other solutions to this question:
1. Deploy a Smart Contract
Develop a contract designed to receive USDT from a user and subsequently send a specific amount of tokens back. This option is preferable, as it ensures a higher level of security for the buyer's payment and token receipt, all within a single transaction. The contract could be structured as follows:
contract IDOSale {
address usdt = address(0xdac17f958d2ee523a2206206994597c13d831ec7);
address token = address(0x<your_token>);
uint256 priceUsdWei = 1 * 10**usdt.decimals();
function buyExactTokens (uint amountWei) external {
require(amount <= token.balanceOf(address(this), "Not enough tokens for sale"));
uint purchasePrice = amountWei * priceUsdWei / 10**token.decimals();
// will revert when not enough approval/balance
usdt.transferFrom(msg.sender, address(this), purchasePrice);
token.transfer(msg.sender, amount);
}
// <... other withdrawal, price update etc. methods ...>
}
2. Create permit signatures
If the token implements EIP-2612: Permit extension:
The backend generates the permit signature and sends it to the user.
The user will submit the transaction to approve the tokens and transfer them.
The method to create the signature will look like the following with the dequanto library:
import { ERC20Permit } from '@dequanto-contracts/openzeppelin/ERC20Permit';
import { Web3ClientFactory } from '@dequanto/clients/Web3ClientFactory';
import { TAddress } from '@dequanto/models/TAddress';
import { $date } from '@dequanto/utils/$date';
import { $signRaw } from '@dequanto/utils/$signRaw';
import { $signSerializer } from '@dequanto/utils/$signSerializer';
const TOKEN_ADDRESS = '0x....';
async function createPermitSignature (owner: { address, key }, receiver: TAddress, amount: bigint) {
const client = Web3ClientFactory.get('eth');
const erc20 = new ERC20Permit(TOKEN_ADDRESS, client);;
const nonce = await erc20.nonces(owner.address);
const deadline = $date.tool().add('1month').toUnixTimestamp();
let typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Permit: [
{ type: 'address', name: 'owner' },
{ type: 'address', name: 'spender' },
{ type: 'uint256', name: 'value' },
{ type: 'uint256', name: 'nonce' },
{ type: 'uint256', name: 'deadline' },
],
},
primaryType: 'Permit',
domain: {
name: 'PermitToken',
version: '1',
chainId: client.chainId,
verifyingContract: erc20.address,
},
message: {
owner: owner.address,
spender: receiver,
value: amount,
nonce: nonce,
deadline: deadline
}
};
let hashed = $signSerializer.serializeTypedData(typedData);
let { v, r, s } = await $signRaw.signEC(hashed, owner.key);
return { v, r, s };
}
However, its capabilities are quite limited due to the nonce validation in ERC20Permit.sol#L57. This means that while it's possible to generate multiple permit signatures for different users, each with a new nonce, the signatures must be sent on-chain in the correct order. You can, however, modify OpenZeppelin's implementation similarly to how it functions in the following example for Account Abstraction Transactions.
For the example I will use the following 4337 setup and dequanto library:
The token distributor creates the ERC4337 Account and transfers the required amount of tokens to that address.
import { ERC20 } from '@dequanto-contracts/openzeppelin/ERC20';
import { BlockChainExplorerProvider } from '@dequanto/BlockchainExplorer/BlockChainExplorerProvider';
import { Web3ClientFactory } from '@dequanto/clients/Web3ClientFactory';
import { Erc4337TxWriter } from '@dequanto/erc4337/Erc4337TxWriter';
const TOKEN = '0x<token>'
async function createAndFundThe4337Account (owner: { key, address }, amount: bigint) {
let client = Web3ClientFactory.get('eth');
let erc4337Writer = await createErc4337Writer();
// checks if the 4337 account for the owner is already deployed
let { address: erc4337Address } = await erc4337Writer.ensureAccount(owner);
let erc20 = new ERC20(TOKEN, client);
// transfer the tokens to the address, so that later the users can claim them
let txTransfer = await erc20.transfer(owner, erc4337Address, amount);
let receipt = await txTransfer.wait();
}
async function createErc4337Writer () {
let client = Web3ClientFactory.get('eth');
let explorer = BlockChainExplorerProvider.get('eth');
let erc4337Writer = new Erc4337TxWriter(client, explorer, {
// 4337 setup requires entrypoint contract and accountFactory contract
addresses: {
entryPoint: erc4337Contracts.entryPointContract.address,
accountFactory: erc4337Contracts.accountFactoryContract.address,
}
});
return erc4337Writer;
}
Now, when the token distributor wants to transfer a particular amount of tokens to the user, they will create the UserOperation and sign it.
import { ERC20 } from '@dequanto-contracts/openzeppelin/ERC20';
import { BlockChainExplorerProvider } from '@dequanto/BlockchainExplorer/BlockChainExplorerProvider';
import { Web3ClientFactory } from '@dequanto/clients/Web3ClientFactory';
import { Erc4337TxWriter } from '@dequanto/erc4337/Erc4337TxWriter';
import { UserOperation } from '@dequanto/erc4337/models/UserOperation';
import { TAddress } from '@dequanto/models/TAddress';
const TOKEN = '0x<token>'
async function prepare4337TransferUserOperation (owner: { key, address }, receiver: TAddress, amount: bigint) {
let erc4337Writer = await createErc4337Writer();
let { address: erc4337Address } = await erc4337Writer.getAccount(owner);
let erc20 = new ERC20(TOKEN);
// Create normal transaction Data for transferring the tokens
let txTransferData = await erc20.$data().transfer(erc4337Address, receiver, amount);
// Pack the transaction data into the UserOperation JSON and also sign it with owner's key
let { op: userOperation } = await erc4337Writer.prepareUserOp({
owner: owner,
tx: txTransferData,
erc4337Account: {
address: erc4337Address
},
// here the key point to be able to generate nonces per receiver address
nonceSalt: BigInt(receiver)
});
return userOperation;
}
The user operation JSON is sent to the user, who will then submit it on-chain with their account, thus paying for the gas..
import { BlockChainExplorerProvider } from '@dequanto/BlockchainExplorer/BlockChainExplorerProvider';
import { Web3ClientFactory } from '@dequanto/clients/Web3ClientFactory';
import { Erc4337TxWriter } from '@dequanto/erc4337/Erc4337TxWriter';
import { UserOperation } from '@dequanto/erc4337/models/UserOperation';
const TOKEN = '0x<token>'
async function submit4337TransferUserOperation (userOperation: UserOperation, receiver: { key, address }) {
let erc4337Writer = await createErc4337Writer();
let tx = await erc4337Writer.submitUserOp(receiver, userOperation);
let txReceipt = await tx.wait();
return txReceipt;
}
The nonce can be created per recipient address - NonceManager.sol#L37 It splits the uin256
nonce into 2 components: prefix
and the incremental
nonce, so that address can be used as the prefix
.