diff --git a/scenarios/uniswap-swaps/README.md b/scenarios/uniswap-swaps/README.md index 960cd850..d328f6b7 100644 --- a/scenarios/uniswap-swaps/README.md +++ b/scenarios/uniswap-swaps/README.md @@ -2,7 +2,9 @@ Execute Uniswap V2 or V3 swaps with configurable parameters. This scenario allows you to test Uniswap DEX interactions by performing buy and sell transactions. -The scenario self-deploys everything it needs on a fresh network: WETH9, mock DAI tokens, two factories (each with its own router) and seeded liquidity. Select the version with `--uniswap-version` (default: 2). In v3 mode each DAI gets a pool on both factories at the configured `--fee-tier`, and swaps are routed through the canonical Uniswap v3 `SwapRouter`. +The scenario self-deploys everything it needs on a fresh network: two factories (each with its own router), a shared mock quote token, one mock DAI token per pair, and seeded liquidity. Every pair trades a DAI token against the quote token; both are plain ERC20s with a public `mint`, so **no ETH capital is needed beyond gas**: liquidity is minted into the pools by the liquidity-provider helper, and each child wallet mints its own quote tokens (see `--quote-funding`) both at startup and whenever it runs low. Select the version with `--uniswap-version` (default: 2). In v3 mode each DAI gets a pool on both factories at the configured `--fee-tier`, and swaps are routed through the canonical Uniswap v3 `SwapRouter`. + +Every pool is seeded with 2000 quote tokens and 20,000,000 DAI, so the initial price is 10,000 DAI per quote token. Swap sizes (`--min-swap`, `--max-swap`, `--sell-threshold`) are denominated in DAI. ## Usage @@ -30,14 +32,15 @@ spamoor uniswap-swaps [flags] ### Swap Settings - `--uniswap-version` - Uniswap version to use, 2 or 3 (default: 2) - `--fee-tier` - Uniswap v3 fee tier in hundredths of a bip: 500, 3000 or 10000 (default: 3000) -- `--pair-count` - Number of uniswap pairs to deploy (default: 1) -- `--min-swap` - Minimum swap amount in wei (default: 100000000000000000) -- `--max-swap` - Maximum swap amount in wei (default: 1000000000000000000000) -- `--buy-ratio` - Ratio of buy vs sell swaps (0-100, default: 50) +- `--pair-count` - Number of uniswap pairs (DAI tokens) to deploy (default: 1) +- `--min-swap` - Minimum swap amount in DAI wei (default: 100000000000000000) +- `--max-swap` - Maximum swap amount in DAI wei (default: 1000000000000000000000) +- `--buy-ratio` - Ratio of buy vs sell swaps (0-100, default: 40) - `--slippage` - Slippage tolerance in basis points (default: 50) - `--slippage-min` - Min per-trade slippage in basis points; 0 disables and uses the fixed `--slippage` (default: 0) - `--slippage-max` - Max per-trade slippage in basis points; when greater than `--slippage-min`, each trade draws a uniform-random tolerance in `[min, max]` (default: 0) -- `--sell-threshold` - DAI balance threshold to force sell in wei (default: 100000000000000000000000) +- `--sell-threshold` - DAI balance threshold to force sell in wei (default: 50000000000000000000000) +- `--quote-funding` - Quote token amount in wei minted to each child wallet at startup and whenever a wallet cannot afford a buy (default: 5000000000000000000, i.e. 5 quote tokens = 50,000 DAI at the seeded price) ### Wallet Management - `--max-wallets` - Maximum number of child wallets to use @@ -45,6 +48,8 @@ spamoor uniswap-swaps [flags] - `--refill-balance` - Minimum ETH balance before refilling (default: 2) - `--refill-interval` - Seconds between balance checks (default: 300) +Child wallets only spend ETH on gas, so the refill settings can be kept low. + ### Client Settings - `--client-group` - Client group to use for sending transactions - `--deploy-client-group` - Client group to use for deployment transactions (same as --client-group if empty) @@ -69,4 +74,9 @@ spamoor uniswap-swaps -p "" -h http://rpc-host:8545 -t 2 --buy-ratio 0 Send Uniswap v3 swaps at the 0.3% fee tier: ```bash spamoor uniswap-swaps -p "" -h http://rpc-host:8545 -t 2 --uniswap-version 3 --fee-tier 3000 -``` \ No newline at end of file +``` + +## Troubleshooting + +- **Wallets keep minting instead of swapping**: `--quote-funding` is too small relative to `--max-swap`. A buy of N DAI costs roughly N / 10,000 quote tokens, so make sure the funding covers many buys. +- **Swaps revert with insufficient output**: the pool price moved more than the slippage tolerance allows between quoting and execution. Raise `--slippage` or lower `--max-swap` so single trades have less price impact. diff --git a/scenarios/uniswap-swaps/contract/PairLiquidityProvider.go b/scenarios/uniswap-swaps/contract/PairLiquidityProvider.go index 928f67ab..6283d407 100644 --- a/scenarios/uniswap-swaps/contract/PairLiquidityProvider.go +++ b/scenarios/uniswap-swaps/contract/PairLiquidityProvider.go @@ -4,9 +4,11 @@ package contract import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,12 +29,14 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // PairLiquidityProviderMetaData contains all meta data concerning the PairLiquidityProvider contract. var PairLiquidityProviderMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner1\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"owner2\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"router1\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"router2\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"weth9\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"addr\",\"type\":\"address\"},{\"internalType\":\"bytes\",\"name\":\"data\",\"type\":\"bytes\"}],\"name\":\"call\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"onERC721Received\",\"outputs\":[{\"internalType\":\"bytes4\",\"name\":\"\",\"type\":\"bytes4\"}],\"stateMutability\":\"pure\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"dai\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"daiDesired\",\"type\":\"uint256\"}],\"name\":\"providePairLiquidity\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"stateMutability\":\"payable\",\"type\":\"receive\"}]", - Bin: "0x608060405234801561001057600080fd5b506040516109c43803806109c483398101604081905261002f916100ad565b600080546001600160a01b03199081166001600160a01b03978816179091556001805482169587169590951790945560028054851693861693909317909255600380548416918516919091179055600480549092169216919091179055610112565b80516001600160a01b03811681146100a857600080fd5b919050565b600080600080600060a086880312156100c557600080fd5b6100ce86610091565b94506100dc60208701610091565b93506100ea60408701610091565b92506100f860608701610091565b915061010660808701610091565b90509295509295909350565b6108a3806101216000396000f3fe6080604052600436106100385760003560e01c8063150b7a02146100445780631b8b921d1461008d5780633f0e37c3146100a257600080fd5b3661003f57005b600080fd5b34801561005057600080fd5b5061007061005f366004610663565b630a85bd0160e11b95945050505050565b6040516001600160e01b0319909116815260200160405180910390f35b6100a061009b3660046106d2565b6100b5565b005b6100a06100b0366004610725565b6101b9565b6000546001600160a01b03163314806100d857506001546001600160a01b031633145b6101155760405162461bcd60e51b81526020600482015260096024820152683737ba1037bbb732b960b91b60448201526064015b60405180910390fd5b6000836001600160a01b031634848460405161013292919061074f565b60006040518083038185875af1925050503d806000811461016f576040519150601f19603f3d011682016040523d82523d6000602084013e610174565b606091505b50509050806101b35760405162461bcd60e51b815260206004820152600b60248201526a18d85b1b0819985a5b195960aa1b604482015260640161010c565b50505050565b6000546001600160a01b03163314806101dc57506001546001600160a01b031633145b6102145760405162461bcd60e51b81526020600482015260096024820152683737ba1037bbb732b960b91b604482015260640161010c565b6001600160a01b03821660009081526005602052604090205460ff161561027d5760405162461bcd60e51b815260206004820152601a60248201527f6c697175696469747920616c7265616479206465706c6f796564000000000000604482015260640161010c565b6001600160a01b0382166000908152600560205260408120805460ff191660011790556102ab60028361075f565b90506102b8816002610781565b6040516340c10f1960e01b8152306004820152602481018290529092506001600160a01b038416906340c10f1990604401600060405180830381600087803b15801561030357600080fd5b505af1158015610317573d6000803e3d6000fd5b505060025461033392508591506001600160a01b0316836104fe565b60035461034b9084906001600160a01b0316836104fe565b600280546001600160a01b03169063f305d71990610369903461075f565b858460008030426040518863ffffffff1660e01b8152600401610391969594939291906107ac565b60606040518083038185885af11580156103af573d6000803e3d6000fd5b50505050506040513d601f19601f820116820180604052508101906103d491906107e7565b50506003546001600160a01b0316905063f305d7196103f460023461075f565b858460008030426040518863ffffffff1660e01b815260040161041c969594939291906107ac565b60606040518083038185885af115801561043a573d6000803e3d6000fd5b50505050506040513d601f19601f8201168201806040525081019061045f91906107e7565b5050471590506104f957604051600090329047908381818185875af1925050503d80600081146104ab576040519150601f19603f3d011682016040523d82523d6000602084013e6104b0565b606091505b50509050806101b35760405162461bcd60e51b815260206004820152601560248201527419985a5b1959081d1bc81cd95b99081c99599d5b99605a1b604482015260640161010c565b505050565b604080516001600160a01b038481166024830152604480830185905283518084039091018152606490920183526020820180516001600160e01b031663095ea7b360e01b179052915160009283929087169161055a9190610815565b6000604051808303816000865af19150503d8060008114610597576040519150601f19603f3d011682016040523d82523d6000602084013e61059c565b606091505b50915091508180156105c65750805115806105c65750808060200190518101906105c69190610844565b6105f75760405162461bcd60e51b8152602060048201526002602482015261534160f01b604482015260640161010c565b5050505050565b80356001600160a01b038116811461061557600080fd5b919050565b60008083601f84011261062c57600080fd5b50813567ffffffffffffffff81111561064457600080fd5b60208301915083602082850101111561065c57600080fd5b9250929050565b60008060008060006080868803121561067b57600080fd5b610684866105fe565b9450610692602087016105fe565b935060408601359250606086013567ffffffffffffffff8111156106b557600080fd5b6106c18882890161061a565b969995985093965092949392505050565b6000806000604084860312156106e757600080fd5b6106f0846105fe565b9250602084013567ffffffffffffffff81111561070c57600080fd5b6107188682870161061a565b9497909650939450505050565b6000806040838503121561073857600080fd5b610741836105fe565b946020939093013593505050565b8183823760009101908152919050565b60008261077c57634e487b7160e01b600052601260045260246000fd5b500490565b80820281158282048414176107a657634e487b7160e01b600052601160045260246000fd5b92915050565b6001600160a01b039687168152602081019590955260408501939093526060840191909152909216608082015260a081019190915260c00190565b6000806000606084860312156107fc57600080fd5b8351925060208401519150604084015190509250925092565b6000825160005b81811015610836576020818601810151858301520161081c565b506000920191825250919050565b60006020828403121561085657600080fd5b8151801515811461086657600080fd5b939250505056fea264697066735822122023a6ff2127f25edf381e1a5ce2fe98d438a91ce501aafe376227c0e576b603af64736f6c63430008110033", + ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"router1\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"router2\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"tokenA\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"tokenB\",\"type\":\"address\"},{\"internalType\":\"uint256\",\"name\":\"amountA\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"amountB\",\"type\":\"uint256\"}],\"name\":\"providePairLiquidity\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + Bin: "0x608060405234801561001057600080fd5b506040516106a43803806106a483398101604081905261002f9161008d565b600080546001600160a01b039485166001600160a01b0319918216179091556001805493851693821693909317909255600280549190931691161790556100d0565b80516001600160a01b038116811461008857600080fd5b919050565b6000806000606084860312156100a257600080fd5b6100ab84610071565b92506100b960208501610071565b91506100c760408501610071565b90509250925092565b6105c5806100df6000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c80630d98c18b14610030575b600080fd5b61004361003e3660046104a9565b610045565b005b6000546001600160a01b031633146100905760405162461bcd60e51b81526020600482015260096024820152683737ba1037bbb732b960b91b60448201526064015b60405180910390fd5b6001600160a01b0380851660009081526003602090815260408083209387168352929052205460ff16156101065760405162461bcd60e51b815260206004820152601a60248201527f6c697175696469747920616c7265616479206465706c6f7965640000000000006044820152606401610087565b6001600160a01b0380851660009081526003602090815260408083209387168352929052908120805460ff191660011790556101436002846104eb565b905060006101526002846104eb565b90506001600160a01b0386166340c10f193061016f85600261050d565b6040516001600160e01b031960e085901b1681526001600160a01b0390921660048301526024820152604401600060405180830381600087803b1580156101b557600080fd5b505af11580156101c9573d6000803e3d6000fd5b50505050846001600160a01b03166340c10f19308360026101ea919061050d565b6040516001600160e01b031960e085901b1681526001600160a01b0390921660048301526024820152604401600060405180830381600087803b15801561023057600080fd5b505af1158015610244573d6000803e3d6000fd5b505060015461026292506001600160a01b0316905087878585610283565b60025461027b906001600160a01b031687878585610283565b505050505050565b60405163095ea7b360e01b81526001600160a01b0386811660048301526024820184905285169063095ea7b3906044016020604051808303816000875af11580156102d2573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102f69190610538565b6103355760405162461bcd60e51b815260206004820152601060248201526f185c1c1c9bdd9948104819985a5b195960821b6044820152606401610087565b60405163095ea7b360e01b81526001600160a01b0386811660048301526024820183905284169063095ea7b3906044016020604051808303816000875af1158015610384573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906103a89190610538565b6103e75760405162461bcd60e51b815260206004820152601060248201526f185c1c1c9bdd9948108819985a5b195960821b6044820152606401610087565b60405162e8e33760e81b81526001600160a01b0385811660048301528481166024830152604482018490526064820183905260006084830181905260a48301523060c48301524260e483015286169063e8e3370090610104016060604051808303816000875af115801561045f573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906104839190610561565b5050505050505050565b80356001600160a01b03811681146104a457600080fd5b919050565b600080600080608085870312156104bf57600080fd5b6104c88561048d565b93506104d66020860161048d565b93969395505050506040820135916060013590565b60008261050857634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761053257634e487b7160e01b600052601160045260246000fd5b92915050565b60006020828403121561054a57600080fd5b8151801515811461055a57600080fd5b9392505050565b60008060006060848603121561057657600080fd5b835192506020840151915060408401519050925092509256fea2646970667358221220c6593ef838bbcceb101eae738f5aad6f487097f88fc6dda5652a2b93a5429d5764736f6c63430008110033", } // PairLiquidityProviderABI is the input ABI used to generate the binding from. @@ -44,7 +48,7 @@ var PairLiquidityProviderABI = PairLiquidityProviderMetaData.ABI var PairLiquidityProviderBin = PairLiquidityProviderMetaData.Bin // DeployPairLiquidityProvider deploys a new Ethereum contract, binding an instance of PairLiquidityProvider to it. -func DeployPairLiquidityProvider(auth *bind.TransactOpts, backend bind.ContractBackend, owner1 common.Address, owner2 common.Address, router1 common.Address, router2 common.Address, weth9 common.Address) (common.Address, *types.Transaction, *PairLiquidityProvider, error) { +func DeployPairLiquidityProvider(auth *bind.TransactOpts, backend bind.ContractBackend, owner common.Address, router1 common.Address, router2 common.Address) (common.Address, *types.Transaction, *PairLiquidityProvider, error) { parsed, err := PairLiquidityProviderMetaData.GetAbi() if err != nil { return common.Address{}, nil, nil, err @@ -53,7 +57,7 @@ func DeployPairLiquidityProvider(auth *bind.TransactOpts, backend bind.ContractB return common.Address{}, nil, nil, errors.New("GetABI returned nil") } - address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(PairLiquidityProviderBin), backend, owner1, owner2, router1, router2, weth9) + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(PairLiquidityProviderBin), backend, owner, router1, router2) if err != nil { return common.Address{}, nil, nil, err } @@ -202,96 +206,23 @@ func (_PairLiquidityProvider *PairLiquidityProviderTransactorRaw) Transact(opts return _PairLiquidityProvider.Contract.contract.Transact(opts, method, params...) } -// OnERC721Received is a free data retrieval call binding the contract method 0x150b7a02. +// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x0d98c18b. // -// Solidity: function onERC721Received(address , address , uint256 , bytes ) pure returns(bytes4) -func (_PairLiquidityProvider *PairLiquidityProviderCaller) OnERC721Received(opts *bind.CallOpts, arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) ([4]byte, error) { - var out []interface{} - err := _PairLiquidityProvider.contract.Call(opts, &out, "onERC721Received", arg0, arg1, arg2, arg3) - - if err != nil { - return *new([4]byte), err - } - - out0 := *abi.ConvertType(out[0], new([4]byte)).(*[4]byte) - - return out0, err - -} - -// OnERC721Received is a free data retrieval call binding the contract method 0x150b7a02. -// -// Solidity: function onERC721Received(address , address , uint256 , bytes ) pure returns(bytes4) -func (_PairLiquidityProvider *PairLiquidityProviderSession) OnERC721Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) ([4]byte, error) { - return _PairLiquidityProvider.Contract.OnERC721Received(&_PairLiquidityProvider.CallOpts, arg0, arg1, arg2, arg3) -} - -// OnERC721Received is a free data retrieval call binding the contract method 0x150b7a02. -// -// Solidity: function onERC721Received(address , address , uint256 , bytes ) pure returns(bytes4) -func (_PairLiquidityProvider *PairLiquidityProviderCallerSession) OnERC721Received(arg0 common.Address, arg1 common.Address, arg2 *big.Int, arg3 []byte) ([4]byte, error) { - return _PairLiquidityProvider.Contract.OnERC721Received(&_PairLiquidityProvider.CallOpts, arg0, arg1, arg2, arg3) -} - -// Call is a paid mutator transaction binding the contract method 0x1b8b921d. -// -// Solidity: function call(address addr, bytes data) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactor) Call(opts *bind.TransactOpts, addr common.Address, data []byte) (*types.Transaction, error) { - return _PairLiquidityProvider.contract.Transact(opts, "call", addr, data) -} - -// Call is a paid mutator transaction binding the contract method 0x1b8b921d. -// -// Solidity: function call(address addr, bytes data) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderSession) Call(addr common.Address, data []byte) (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.Call(&_PairLiquidityProvider.TransactOpts, addr, data) -} - -// Call is a paid mutator transaction binding the contract method 0x1b8b921d. -// -// Solidity: function call(address addr, bytes data) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactorSession) Call(addr common.Address, data []byte) (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.Call(&_PairLiquidityProvider.TransactOpts, addr, data) -} - -// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x3f0e37c3. -// -// Solidity: function providePairLiquidity(address dai, uint256 daiDesired) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactor) ProvidePairLiquidity(opts *bind.TransactOpts, dai common.Address, daiDesired *big.Int) (*types.Transaction, error) { - return _PairLiquidityProvider.contract.Transact(opts, "providePairLiquidity", dai, daiDesired) -} - -// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x3f0e37c3. -// -// Solidity: function providePairLiquidity(address dai, uint256 daiDesired) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderSession) ProvidePairLiquidity(dai common.Address, daiDesired *big.Int) (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.ProvidePairLiquidity(&_PairLiquidityProvider.TransactOpts, dai, daiDesired) -} - -// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x3f0e37c3. -// -// Solidity: function providePairLiquidity(address dai, uint256 daiDesired) payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactorSession) ProvidePairLiquidity(dai common.Address, daiDesired *big.Int) (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.ProvidePairLiquidity(&_PairLiquidityProvider.TransactOpts, dai, daiDesired) -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactor) Receive(opts *bind.TransactOpts) (*types.Transaction, error) { - return _PairLiquidityProvider.contract.RawTransact(opts, nil) // calldata is disallowed for receive function +// Solidity: function providePairLiquidity(address tokenA, address tokenB, uint256 amountA, uint256 amountB) returns() +func (_PairLiquidityProvider *PairLiquidityProviderTransactor) ProvidePairLiquidity(opts *bind.TransactOpts, tokenA common.Address, tokenB common.Address, amountA *big.Int, amountB *big.Int) (*types.Transaction, error) { + return _PairLiquidityProvider.contract.Transact(opts, "providePairLiquidity", tokenA, tokenB, amountA, amountB) } -// Receive is a paid mutator transaction binding the contract receive function. +// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x0d98c18b. // -// Solidity: receive() payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderSession) Receive() (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.Receive(&_PairLiquidityProvider.TransactOpts) +// Solidity: function providePairLiquidity(address tokenA, address tokenB, uint256 amountA, uint256 amountB) returns() +func (_PairLiquidityProvider *PairLiquidityProviderSession) ProvidePairLiquidity(tokenA common.Address, tokenB common.Address, amountA *big.Int, amountB *big.Int) (*types.Transaction, error) { + return _PairLiquidityProvider.Contract.ProvidePairLiquidity(&_PairLiquidityProvider.TransactOpts, tokenA, tokenB, amountA, amountB) } -// Receive is a paid mutator transaction binding the contract receive function. +// ProvidePairLiquidity is a paid mutator transaction binding the contract method 0x0d98c18b. // -// Solidity: receive() payable returns() -func (_PairLiquidityProvider *PairLiquidityProviderTransactorSession) Receive() (*types.Transaction, error) { - return _PairLiquidityProvider.Contract.Receive(&_PairLiquidityProvider.TransactOpts) +// Solidity: function providePairLiquidity(address tokenA, address tokenB, uint256 amountA, uint256 amountB) returns() +func (_PairLiquidityProvider *PairLiquidityProviderTransactorSession) ProvidePairLiquidity(tokenA common.Address, tokenB common.Address, amountA *big.Int, amountB *big.Int) (*types.Transaction, error) { + return _PairLiquidityProvider.Contract.ProvidePairLiquidity(&_PairLiquidityProvider.TransactOpts, tokenA, tokenB, amountA, amountB) } diff --git a/scenarios/uniswap-swaps/contract/PairLiquidityProvider.sol b/scenarios/uniswap-swaps/contract/PairLiquidityProvider.sol index 25203290..18b9ee1a 100644 --- a/scenarios/uniswap-swaps/contract/PairLiquidityProvider.sol +++ b/scenarios/uniswap-swaps/contract/PairLiquidityProvider.sol @@ -1,101 +1,18 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.17; -interface IERC721Receiver { - /** - * @dev Whenever an {IERC721} `tokenId` token is transferred to this contract via {IERC721-safeTransferFrom} - * by `operator` from `from`, this function is called. - * - * It must return its Solidity selector to confirm the token transfer. - * If any other value is returned or the interface is not implemented by the recipient, the transfer will be - * reverted. - * - * The selector can be obtained in Solidity with `IERC721Receiver.onERC721Received.selector`. - */ - function onERC721Received( - address operator, - address from, - uint256 tokenId, - bytes calldata data - ) external returns (bytes4); -} - -interface IERC20 { - event Transfer(address indexed from, address indexed to, uint256 value); - event Approval(address indexed owner, address indexed spender, uint256 value); +// Minimal helper for the spamoor uniswap-swaps scenario (v2 mode). +// +// It seeds an ERC20/ERC20 pair on both routers in a single tx, minting both +// tokens on demand. The scenario's tokens are mock tokens with a public mint, +// so the helper never needs to be funded: the caller only pays gas. - function totalSupply() external view returns (uint256); - function balanceOf(address account) external view returns (uint256); - function transfer(address to, uint256 value) external returns (bool); - function allowance(address owner, address spender) external view returns (uint256); +interface IMintableERC20 { function approve(address spender, uint256 value) external returns (bool); - function transferFrom(address from, address to, uint256 value) external returns (bool); -} - -interface IDai is IERC20 { function mint(address to, uint256 amount) external; } -library TransferHelper { - /// @notice Transfers tokens from the targeted address to the given destination - /// @notice Errors with 'STF' if transfer fails - /// @param token The contract address of the token to be transferred - /// @param from The originating address from which the tokens will be transferred - /// @param to The destination address of the transfer - /// @param value The amount to be transferred - function safeTransferFrom( - address token, - address from, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = - token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'STF'); - } - - /// @notice Transfers tokens from msg.sender to a recipient - /// @dev Errors with ST if transfer fails - /// @param token The contract address of the token which will be transferred - /// @param to The recipient of the transfer - /// @param value The value of the transfer - function safeTransfer( - address token, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.transfer.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'ST'); - } - - /// @notice Approves the stipulated contract to spend the given allowance in the given token - /// @dev Errors with 'SA' if transfer fails - /// @param token The contract address of the token to be approved - /// @param to The target of the approval - /// @param value The amount of the given token the target will be allowed to spend - function safeApprove( - address token, - address to, - uint256 value - ) internal { - (bool success, bytes memory data) = token.call(abi.encodeWithSelector(IERC20.approve.selector, to, value)); - require(success && (data.length == 0 || abi.decode(data, (bool))), 'SA'); - } - - /// @notice Transfers ETH to the recipient address - /// @dev Fails with `STE` - /// @param to The destination of the transfer - /// @param value The value to be transferred - function safeTransferETH(address to, uint256 value) internal { - (bool success, ) = to.call{value: value}(new bytes(0)); - require(success, 'STE'); - } -} - -interface IUniswapV2Router01 { - function factory() external pure returns (address); - function WETH() external pure returns (address); - +interface IUniswapV2Router02 { function addLiquidity( address tokenA, address tokenB, @@ -106,183 +23,42 @@ interface IUniswapV2Router01 { address to, uint deadline ) external returns (uint amountA, uint amountB, uint liquidity); - function addLiquidityETH( - address token, - uint amountTokenDesired, - uint amountTokenMin, - uint amountETHMin, - address to, - uint deadline - ) external payable returns (uint amountToken, uint amountETH, uint liquidity); - function removeLiquidity( - address tokenA, - address tokenB, - uint liquidity, - uint amountAMin, - uint amountBMin, - address to, - uint deadline - ) external returns (uint amountA, uint amountB); - function removeLiquidityETH( - address token, - uint liquidity, - uint amountTokenMin, - uint amountETHMin, - address to, - uint deadline - ) external returns (uint amountToken, uint amountETH); - function removeLiquidityWithPermit( - address tokenA, - address tokenB, - uint liquidity, - uint amountAMin, - uint amountBMin, - address to, - uint deadline, - bool approveMax, uint8 v, bytes32 r, bytes32 s - ) external returns (uint amountA, uint amountB); - function removeLiquidityETHWithPermit( - address token, - uint liquidity, - uint amountTokenMin, - uint amountETHMin, - address to, - uint deadline, - bool approveMax, uint8 v, bytes32 r, bytes32 s - ) external returns (uint amountToken, uint amountETH); - function swapExactTokensForTokens( - uint amountIn, - uint amountOutMin, - address[] calldata path, - address to, - uint deadline - ) external returns (uint[] memory amounts); - function swapTokensForExactTokens( - uint amountOut, - uint amountInMax, - address[] calldata path, - address to, - uint deadline - ) external returns (uint[] memory amounts); - function swapExactETHForTokens(uint amountOutMin, address[] calldata path, address to, uint deadline) - external - payable - returns (uint[] memory amounts); - function swapTokensForExactETH(uint amountOut, uint amountInMax, address[] calldata path, address to, uint deadline) - external - returns (uint[] memory amounts); - function swapExactTokensForETH(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) - external - returns (uint[] memory amounts); - function swapETHForExactTokens(uint amountOut, address[] calldata path, address to, uint deadline) - external - payable - returns (uint[] memory amounts); - - function quote(uint amountA, uint reserveA, uint reserveB) external pure returns (uint amountB); - function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) external pure returns (uint amountOut); - function getAmountIn(uint amountOut, uint reserveIn, uint reserveOut) external pure returns (uint amountIn); - function getAmountsOut(uint amountIn, address[] calldata path) external view returns (uint[] memory amounts); - function getAmountsIn(uint amountOut, address[] calldata path) external view returns (uint[] memory amounts); -} - -interface IUniswapV2Router02 is IUniswapV2Router01 { - function removeLiquidityETHSupportingFeeOnTransferTokens( - address token, - uint liquidity, - uint amountTokenMin, - uint amountETHMin, - address to, - uint deadline - ) external returns (uint amountETH); - function removeLiquidityETHWithPermitSupportingFeeOnTransferTokens( - address token, - uint liquidity, - uint amountTokenMin, - uint amountETHMin, - address to, - uint deadline, - bool approveMax, uint8 v, bytes32 r, bytes32 s - ) external returns (uint amountETH); - - function swapExactTokensForTokensSupportingFeeOnTransferTokens( - uint amountIn, - uint amountOutMin, - address[] calldata path, - address to, - uint deadline - ) external; - function swapExactETHForTokensSupportingFeeOnTransferTokens( - uint amountOutMin, - address[] calldata path, - address to, - uint deadline - ) external payable; - function swapExactTokensForETHSupportingFeeOnTransferTokens( - uint amountIn, - uint amountOutMin, - address[] calldata path, - address to, - uint deadline - ) external; } -contract PairLiquidityProvider is IERC721Receiver { - address private _owner1; - address private _owner2; +contract PairLiquidityProvider { + address private _owner; address private _router1; address private _router2; - address private _weth9; - mapping(address => bool) private _deployedLiquidity; + mapping(address => mapping(address => bool)) private _deployedLiquidity; - constructor(address owner1, address owner2, address router1, address router2, address weth9) { - _owner1 = owner1; - _owner2 = owner2; + constructor(address owner, address router1, address router2) { + _owner = owner; _router1 = router1; _router2 = router2; - _weth9 = weth9; } - receive() external payable { - } + // providePairLiquidity mints amountA of tokenA and amountB of tokenB and + // splits them evenly between the tokenA/tokenB pair on router1 and the one + // on router2 (creating the pairs if needed). The LP tokens stay locked in + // this contract. + function providePairLiquidity(address tokenA, address tokenB, uint256 amountA, uint256 amountB) external { + require(msg.sender == _owner, "not owner"); + require(!_deployedLiquidity[tokenA][tokenB], "liquidity already deployed"); + _deployedLiquidity[tokenA][tokenB] = true; - function onERC721Received(address, address, uint256, bytes calldata) public pure override returns (bytes4) { - return this.onERC721Received.selector; - } + uint256 amountAPerPair = amountA / 2; + uint256 amountBPerPair = amountB / 2; - function call(address addr, bytes calldata data) public payable { - require(msg.sender == _owner1 || msg.sender == _owner2, "not owner"); + IMintableERC20(tokenA).mint(address(this), amountAPerPair * 2); + IMintableERC20(tokenB).mint(address(this), amountBPerPair * 2); - (bool sent, ) = payable(addr).call{value: msg.value}(data); - require(sent, "call failed"); + _addLiquidity(_router1, tokenA, tokenB, amountAPerPair, amountBPerPair); + _addLiquidity(_router2, tokenA, tokenB, amountAPerPair, amountBPerPair); } - function providePairLiquidity(address dai, uint256 daiDesired) public payable { - require(msg.sender == _owner1 || msg.sender == _owner2, "not owner"); - require(!_deployedLiquidity[dai], "liquidity already deployed"); - _deployedLiquidity[dai] = true; - - uint256 daiPerPair = daiDesired/2; - daiDesired = daiPerPair * 2; - - // mint dai for both pairs - IDai(dai).mint(address(this), daiDesired); - - // approve dai for both pairs - TransferHelper.safeApprove(dai, _router1, daiPerPair); - TransferHelper.safeApprove(dai, _router2, daiPerPair); - - // provide liquidity to router1 - IUniswapV2Router02(_router1).addLiquidityETH{value: msg.value/2}(dai, daiPerPair, 0, 0, address(this), block.timestamp); - - // provide liquidity to router2 - IUniswapV2Router02(_router2).addLiquidityETH{value: msg.value/2}(dai, daiPerPair, 0, 0, address(this), block.timestamp); - - // return remaining eth back to sender - if(address(this).balance > 0) { - (bool sent, ) = payable(tx.origin).call{value: address(this).balance}(""); - require(sent, "failed to send refund"); - } + function _addLiquidity(address router, address tokenA, address tokenB, uint256 amountA, uint256 amountB) private { + require(IMintableERC20(tokenA).approve(router, amountA), "approve A failed"); + require(IMintableERC20(tokenB).approve(router, amountB), "approve B failed"); + IUniswapV2Router02(router).addLiquidity(tokenA, tokenB, amountA, amountB, 0, 0, address(this), block.timestamp); } - -} \ No newline at end of file +} diff --git a/scenarios/uniswap-swaps/contract/V3LiquidityProvider.go b/scenarios/uniswap-swaps/contract/V3LiquidityProvider.go index 4e80ff05..1f6e6ecf 100644 --- a/scenarios/uniswap-swaps/contract/V3LiquidityProvider.go +++ b/scenarios/uniswap-swaps/contract/V3LiquidityProvider.go @@ -4,9 +4,11 @@ package contract import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,12 +29,14 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // V3LiquidityProviderMetaData contains all meta data concerning the V3LiquidityProvider contract. var V3LiquidityProviderMetaData = &bind.MetaData{ - ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner1\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"owner2\",\"type\":\"address\"},{\"internalType\":\"address\",\"name\":\"weth9\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"pool\",\"type\":\"address\"},{\"internalType\":\"int24\",\"name\":\"tickLower\",\"type\":\"int24\"},{\"internalType\":\"int24\",\"name\":\"tickUpper\",\"type\":\"int24\"},{\"internalType\":\"uint128\",\"name\":\"liquidity\",\"type\":\"uint128\"}],\"name\":\"provideLiquidity\",\"outputs\":[],\"stateMutability\":\"payable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amount0Owed\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"amount1Owed\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"uniswapV3MintCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"stateMutability\":\"payable\",\"type\":\"receive\"}]", - Bin: "0x608060405234801561001057600080fd5b5060405161074138038061074183398101604081905261002f9161008d565b600080546001600160a01b039485166001600160a01b0319918216179091556001805493851693821693909317909255600280549190931691161790556100d0565b80516001600160a01b038116811461008857600080fd5b919050565b6000806000606084860312156100a257600080fd5b6100ab84610071565b92506100b960208501610071565b91506100c760408501610071565b90509250925092565b610662806100df6000396000f3fe60806040526004361061002d5760003560e01c80632b27850814610039578063d34879971461004e57600080fd5b3661003457005b600080fd5b61004c6100473660046104de565b61006e565b005b34801561005a57600080fd5b5061004c610069366004610542565b610223565b6000546001600160a01b031633148061009157506001546001600160a01b031633145b6100ce5760405162461bcd60e51b81526020600482015260096024820152683737ba1037bbb732b960b91b60448201526064015b60405180910390fd5b600380546001600160a01b0319166001600160a01b038616908117909155604051633c8a7d8d60e01b8152306004820152600285810b602483015284900b60448201526001600160801b038316606482015260a06084820152600060a4820152633c8a7d8d9060c40160408051808303816000875af1158015610155573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061017991906105c2565b5050600380546001600160a01b031916905547801561021c57604051600090329083908381818185875af1925050503d80600081146101d4576040519150601f19603f3d011682016040523d82523d6000602084013e6101d9565b606091505b505090508061021a5760405162461bcd60e51b815260206004820152600d60248201526c1c99599d5b990819985a5b1959609a1b60448201526064016100c5565b505b5050505050565b6003546001600160a01b031633146102715760405162461bcd60e51b81526020600482015260116024820152703ab732bc3832b1ba32b21031b0b63632b960791b60448201526064016100c5565b6000336001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102b1573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102d591906105e6565b90506000336001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa158015610317573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061033b91906105e6565b9050851561034d5761034d8287610359565b841561021a5761021a81865b6002546001600160a01b039081169083160361045357600260009054906101000a90046001600160a01b03166001600160a01b031663d0e30db0826040518263ffffffff1660e01b81526004016000604051808303818588803b1580156103bf57600080fd5b505af11580156103d3573d6000803e3d6000fd5b505060025460405163a9059cbb60e01b8152336004820152602481018690526001600160a01b03909116935063a9059cbb925060440190506020604051808303816000875af115801561042a573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061044e919061060a565b505050565b6040516340c10f1960e01b8152336004820152602481018290526001600160a01b038316906340c10f1990604401600060405180830381600087803b15801561049b57600080fd5b505af115801561021a573d6000803e3d6000fd5b6001600160a01b03811681146104c457600080fd5b50565b8035600281900b81146104d957600080fd5b919050565b600080600080608085870312156104f457600080fd5b84356104ff816104af565b935061050d602086016104c7565b925061051b604086016104c7565b915060608501356001600160801b038116811461053757600080fd5b939692955090935050565b6000806000806060858703121561055857600080fd5b8435935060208501359250604085013567ffffffffffffffff8082111561057e57600080fd5b818701915087601f83011261059257600080fd5b8135818111156105a157600080fd5b8860208285010111156105b357600080fd5b95989497505060200194505050565b600080604083850312156105d557600080fd5b505080516020909101519092909150565b6000602082840312156105f857600080fd5b8151610603816104af565b9392505050565b60006020828403121561061c57600080fd5b8151801515811461060357600080fdfea2646970667358221220f85f4620491245007428e1aade4551bdfbbc1c53b9a953ea41e94c712d906ed564736f6c63430008110033", + ABI: "[{\"inputs\":[{\"internalType\":\"address\",\"name\":\"owner\",\"type\":\"address\"}],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"pool\",\"type\":\"address\"},{\"internalType\":\"int24\",\"name\":\"tickLower\",\"type\":\"int24\"},{\"internalType\":\"int24\",\"name\":\"tickUpper\",\"type\":\"int24\"},{\"internalType\":\"uint128\",\"name\":\"liquidity\",\"type\":\"uint128\"}],\"name\":\"provideLiquidity\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amount0Owed\",\"type\":\"uint256\"},{\"internalType\":\"uint256\",\"name\":\"amount1Owed\",\"type\":\"uint256\"},{\"internalType\":\"bytes\",\"name\":\"\",\"type\":\"bytes\"}],\"name\":\"uniswapV3MintCallback\",\"outputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + Bin: "0x608060405234801561001057600080fd5b5060405161057f38038061057f83398101604081905261002f91610054565b600080546001600160a01b0319166001600160a01b0392909216919091179055610084565b60006020828403121561006657600080fd5b81516001600160a01b038116811461007d57600080fd5b9392505050565b6104ec806100936000396000f3fe608060405234801561001057600080fd5b50600436106100365760003560e01c80632b2785081461003b578063d348799714610050575b600080fd5b61004e61004936600461038a565b610063565b005b61004e61005e3660046103ee565b610171565b6000546001600160a01b031633146100ae5760405162461bcd60e51b81526020600482015260096024820152683737ba1037bbb732b960b91b60448201526064015b60405180910390fd5b600180546001600160a01b0319166001600160a01b038616908117909155604051633c8a7d8d60e01b8152306004820152600285810b602483015284900b60448201526001600160801b038316606482015260a06084820152600060a4820152633c8a7d8d9060c40160408051808303816000875af1158015610135573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610159919061046e565b5050600180546001600160a01b031916905550505050565b6001546001600160a01b031633146101bf5760405162461bcd60e51b81526020600482015260116024820152703ab732bc3832b1ba32b21031b0b63632b960791b60448201526064016100a5565b831561028a57336001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa158015610203573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102279190610492565b6040516340c10f1960e01b8152336004820152602481018690526001600160a01b0391909116906340c10f1990604401600060405180830381600087803b15801561027157600080fd5b505af1158015610285573d6000803e3d6000fd5b505050505b821561035557336001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa1580156102ce573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906102f29190610492565b6040516340c10f1960e01b8152336004820152602481018590526001600160a01b0391909116906340c10f1990604401600060405180830381600087803b15801561033c57600080fd5b505af1158015610350573d6000803e3d6000fd5b505050505b50505050565b6001600160a01b038116811461037057600080fd5b50565b8035600281900b811461038557600080fd5b919050565b600080600080608085870312156103a057600080fd5b84356103ab8161035b565b93506103b960208601610373565b92506103c760408601610373565b915060608501356001600160801b03811681146103e357600080fd5b939692955090935050565b6000806000806060858703121561040457600080fd5b8435935060208501359250604085013567ffffffffffffffff8082111561042a57600080fd5b818701915087601f83011261043e57600080fd5b81358181111561044d57600080fd5b88602082850101111561045f57600080fd5b95989497505060200194505050565b6000806040838503121561048157600080fd5b505080516020909101519092909150565b6000602082840312156104a457600080fd5b81516104af8161035b565b939250505056fea26469706673582212208c4b3f6b983a1b5ab98728fca626459dcbfa23a216091deaf779d11d17ef132a64736f6c63430008110033", } // V3LiquidityProviderABI is the input ABI used to generate the binding from. @@ -44,7 +48,7 @@ var V3LiquidityProviderABI = V3LiquidityProviderMetaData.ABI var V3LiquidityProviderBin = V3LiquidityProviderMetaData.Bin // DeployV3LiquidityProvider deploys a new Ethereum contract, binding an instance of V3LiquidityProvider to it. -func DeployV3LiquidityProvider(auth *bind.TransactOpts, backend bind.ContractBackend, owner1 common.Address, owner2 common.Address, weth9 common.Address) (common.Address, *types.Transaction, *V3LiquidityProvider, error) { +func DeployV3LiquidityProvider(auth *bind.TransactOpts, backend bind.ContractBackend, owner common.Address) (common.Address, *types.Transaction, *V3LiquidityProvider, error) { parsed, err := V3LiquidityProviderMetaData.GetAbi() if err != nil { return common.Address{}, nil, nil, err @@ -53,7 +57,7 @@ func DeployV3LiquidityProvider(auth *bind.TransactOpts, backend bind.ContractBac return common.Address{}, nil, nil, errors.New("GetABI returned nil") } - address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(V3LiquidityProviderBin), backend, owner1, owner2, weth9) + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(V3LiquidityProviderBin), backend, owner) if err != nil { return common.Address{}, nil, nil, err } @@ -204,21 +208,21 @@ func (_V3LiquidityProvider *V3LiquidityProviderTransactorRaw) Transact(opts *bin // ProvideLiquidity is a paid mutator transaction binding the contract method 0x2b278508. // -// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) payable returns() +// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) returns() func (_V3LiquidityProvider *V3LiquidityProviderTransactor) ProvideLiquidity(opts *bind.TransactOpts, pool common.Address, tickLower *big.Int, tickUpper *big.Int, liquidity *big.Int) (*types.Transaction, error) { return _V3LiquidityProvider.contract.Transact(opts, "provideLiquidity", pool, tickLower, tickUpper, liquidity) } // ProvideLiquidity is a paid mutator transaction binding the contract method 0x2b278508. // -// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) payable returns() +// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) returns() func (_V3LiquidityProvider *V3LiquidityProviderSession) ProvideLiquidity(pool common.Address, tickLower *big.Int, tickUpper *big.Int, liquidity *big.Int) (*types.Transaction, error) { return _V3LiquidityProvider.Contract.ProvideLiquidity(&_V3LiquidityProvider.TransactOpts, pool, tickLower, tickUpper, liquidity) } // ProvideLiquidity is a paid mutator transaction binding the contract method 0x2b278508. // -// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) payable returns() +// Solidity: function provideLiquidity(address pool, int24 tickLower, int24 tickUpper, uint128 liquidity) returns() func (_V3LiquidityProvider *V3LiquidityProviderTransactorSession) ProvideLiquidity(pool common.Address, tickLower *big.Int, tickUpper *big.Int, liquidity *big.Int) (*types.Transaction, error) { return _V3LiquidityProvider.Contract.ProvideLiquidity(&_V3LiquidityProvider.TransactOpts, pool, tickLower, tickUpper, liquidity) } @@ -243,24 +247,3 @@ func (_V3LiquidityProvider *V3LiquidityProviderSession) UniswapV3MintCallback(am func (_V3LiquidityProvider *V3LiquidityProviderTransactorSession) UniswapV3MintCallback(amount0Owed *big.Int, amount1Owed *big.Int, arg2 []byte) (*types.Transaction, error) { return _V3LiquidityProvider.Contract.UniswapV3MintCallback(&_V3LiquidityProvider.TransactOpts, amount0Owed, amount1Owed, arg2) } - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_V3LiquidityProvider *V3LiquidityProviderTransactor) Receive(opts *bind.TransactOpts) (*types.Transaction, error) { - return _V3LiquidityProvider.contract.RawTransact(opts, nil) // calldata is disallowed for receive function -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_V3LiquidityProvider *V3LiquidityProviderSession) Receive() (*types.Transaction, error) { - return _V3LiquidityProvider.Contract.Receive(&_V3LiquidityProvider.TransactOpts) -} - -// Receive is a paid mutator transaction binding the contract receive function. -// -// Solidity: receive() payable returns() -func (_V3LiquidityProvider *V3LiquidityProviderTransactorSession) Receive() (*types.Transaction, error) { - return _V3LiquidityProvider.Contract.Receive(&_V3LiquidityProvider.TransactOpts) -} diff --git a/scenarios/uniswap-swaps/contract/V3LiquidityProvider.sol b/scenarios/uniswap-swaps/contract/V3LiquidityProvider.sol index 5bfb1dab..56eb01c1 100644 --- a/scenarios/uniswap-swaps/contract/V3LiquidityProvider.sol +++ b/scenarios/uniswap-swaps/contract/V3LiquidityProvider.sol @@ -4,24 +4,16 @@ pragma solidity ^0.8.17; // Minimal helper for the spamoor uniswap-swaps scenario (v3 mode). // // It seeds a full-range liquidity position into a v3 pool in a single tx, -// minting DAI on demand and wrapping ETH from msg.value inside the mint -// callback. This mirrors the PairLiquidityProvider pattern used by the v2 path. -// Swaps are NOT handled here - they are routed through the canonical Uniswap v3 -// SwapRouter. +// minting both pool tokens on demand inside the mint callback. The scenario's +// tokens are mock tokens with a public mint, so the helper never needs to be +// funded: the caller only pays gas. This mirrors the PairLiquidityProvider +// pattern used by the v2 path. Swaps are NOT handled here - they are routed +// through the canonical Uniswap v3 SwapRouter. -interface IERC20 { - function transfer(address to, uint256 value) external returns (bool); - function balanceOf(address account) external view returns (uint256); -} - -interface IDai is IERC20 { +interface IMintableERC20 { function mint(address to, uint256 amount) external; } -interface IWETH9 is IERC20 { - function deposit() external payable; -} - interface IUniswapV3Pool { function token0() external view returns (address); function token1() external view returns (address); @@ -36,65 +28,40 @@ interface IUniswapV3Pool { } contract V3LiquidityProvider { - address private _owner1; - address private _owner2; - address private _weth9; + address private _owner; // Expected callback caller (the pool) for the in-flight mint. Set right // before calling into the pool and cleared after; the callback requires // msg.sender to match so it cannot be invoked directly. address private _expectedPool; - constructor(address owner1, address owner2, address weth9) { - _owner1 = owner1; - _owner2 = owner2; - _weth9 = weth9; + constructor(address owner) { + _owner = owner; } - receive() external payable {} - - // provideLiquidity seeds a full-range position into the given pool. DAI is - // minted on demand and WETH is wrapped from msg.value inside the callback; - // any unused ETH is refunded to the original sender. + // provideLiquidity seeds a position into the given pool. Both tokens are + // minted on demand inside the callback, so no prior funding is needed. function provideLiquidity( address pool, int24 tickLower, int24 tickUpper, uint128 liquidity - ) external payable { - require(msg.sender == _owner1 || msg.sender == _owner2, "not owner"); + ) external { + require(msg.sender == _owner, "not owner"); _expectedPool = pool; IUniswapV3Pool(pool).mint(address(this), tickLower, tickUpper, liquidity, ""); _expectedPool = address(0); - - uint256 bal = address(this).balance; - if (bal > 0) { - (bool sent, ) = payable(tx.origin).call{value: bal}(""); - require(sent, "refund failed"); - } } function uniswapV3MintCallback(uint256 amount0Owed, uint256 amount1Owed, bytes calldata) external { require(msg.sender == _expectedPool, "unexpected caller"); - address token0 = IUniswapV3Pool(msg.sender).token0(); - address token1 = IUniswapV3Pool(msg.sender).token1(); - if (amount0Owed > 0) { - _payMint(token0, amount0Owed); + IMintableERC20(IUniswapV3Pool(msg.sender).token0()).mint(msg.sender, amount0Owed); } if (amount1Owed > 0) { - _payMint(token1, amount1Owed); - } - } - - function _payMint(address token, uint256 amount) private { - if (token == _weth9) { - IWETH9(_weth9).deposit{value: amount}(); - IWETH9(_weth9).transfer(msg.sender, amount); - } else { - IDai(token).mint(msg.sender, amount); + IMintableERC20(IUniswapV3Pool(msg.sender).token1()).mint(msg.sender, amount1Owed); } } } diff --git a/scenarios/uniswap-swaps/deployment.go b/scenarios/uniswap-swaps/deployment.go index 98a7e1d2..77829874 100644 --- a/scenarios/uniswap-swaps/deployment.go +++ b/scenarios/uniswap-swaps/deployment.go @@ -18,9 +18,15 @@ import ( "github.com/ethpandaops/spamoor/txtypes" ) +// DeploymentInfo holds the deployed Uniswap v2 contract set for the scenario. +// Two factories (each with its own router) are deployed so that every DAI +// token gets a pair with the shared quote token on both factories. type DeploymentInfo struct { + // Weth9Addr is only needed because the canonical router requires a WETH + // address at construction; the scenario never trades ETH/WETH. Weth9Addr common.Address - Weth9 *contract.WETH9 + QuoteAddr common.Address + Quote *contract.Dai UniswapFactoryAAddr common.Address UniswapFactoryA *contract.UniswapV2Factory UniswapRouterAAddr common.Address @@ -43,6 +49,15 @@ type PairDeploymentInfo struct { PairB *contract.UniswapV2Pair } +// Token deployment salts. The DAI/quote tokens are deployed "globally" (seed +// without the deployer address) and share identical init code, so the salt is +// all that distinguishes them: the quote token takes salt 0 and the DAI tokens +// take 1..PairCount. +const ( + quoteTokenSalt = 0 + daiTokenSalt = 1 +) + func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { client := u.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), @@ -53,6 +68,11 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { } deployerWallet := u.walletPool.GetWellKnownWallet("deployer") + ownerWallet := u.walletPool.GetWellKnownWallet("owner") + if deployerWallet == nil || ownerWallet == nil { + return nil, scenario.ErrNoWallet + } + deployerSeed := [32]byte{} copy(deployerSeed[:], deployerWallet.GetAddress().Bytes()) @@ -60,14 +80,6 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { copy(deployerSeed[20:], []byte(fmt.Sprintf("%x", deployerWallet.GetNonce()+1))) } - ownerWallet := u.walletPool.GetWellKnownWallet("owner") - if deployerWallet == nil { - return nil, scenario.ErrNoWallet - } - if ownerWallet == nil { - return nil, scenario.ErrNoWallet - } - baseFeeWei, tipFeeWei := spamoor.ResolveFees(u.options.BaseFee, u.options.TipFee, u.options.BaseFeeWei, u.options.TipFeeWei) feeCap, tipCap, err := u.walletPool.GetSuggestedFees(client, baseFeeWei, tipFeeWei) if err != nil { @@ -99,9 +111,9 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { copy(seed[:], deployerSeed[:]) } if salt != 0 { - binary.BigEndian.PutUint32(deployerSeed[28:], salt) + binary.BigEndian.PutUint32(seed[28:], salt) } - addr, tx, err := u.walletPool.GetDeploymentFactory().GetContractDeployment(u.ctx, initCodeBytes, deployerSeed, client, deployerWallet, feeCap, tipCap, false) + addr, tx, err := u.walletPool.GetDeploymentFactory().GetContractDeployment(u.ctx, initCodeBytes, seed, client, deployerWallet, feeCap, tipCap, false) if err != nil { return common.Address{}, err } @@ -113,15 +125,11 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { return addr, nil } - // deploy WETH9 + // deploy WETH9 (router constructor dependency only) deploymentInfo.Weth9Addr, err = deployContract(contract.WETH9MetaData, true, 0) if err != nil { return nil, fmt.Errorf("could not deploy WETH9: %w", err) } - deploymentInfo.Weth9, err = contract.NewWETH9(deploymentInfo.Weth9Addr, client.GetEthClient()) - if err != nil { - return nil, fmt.Errorf("could not create instance of WETH9: %w", err) - } // deploy uniswap factory A deploymentInfo.UniswapFactoryAAddr, err = deployContract(contract.UniswapV2FactoryMetaData, false, 0, ownerWallet.GetAddress()) @@ -163,14 +171,13 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { return nil, fmt.Errorf("could not create instance of uniswap v2 router B: %w", err) } - // deploy pair liquidity provider + // deploy pair liquidity provider (owner-only helper that mints both tokens + // on demand, so it needs no funding) deploymentInfo.LiquidityProviderAddr, err = deployContract( contract.PairLiquidityProviderMetaData, false, 0, ownerWallet.GetAddress(), - u.walletPool.GetRootWallet().GetWallet().GetAddress(), deploymentInfo.UniswapRouterAAddr, deploymentInfo.UniswapRouterBAddr, - deploymentInfo.Weth9Addr, ) if err != nil { return nil, fmt.Errorf("could not deploy pair liquidity provider: %w", err) @@ -180,17 +187,25 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { return nil, fmt.Errorf("could not create instance of pair liquidity provider: %w", err) } - // deploy tokens and uniswap pairs + // deploy the shared quote token + deploymentInfo.QuoteAddr, err = deployContract(contract.DaiMetaData, true, quoteTokenSalt, deployerWallet.GetChainId()) + if err != nil { + return nil, fmt.Errorf("could not deploy quote token: %w", err) + } + deploymentInfo.Quote, err = contract.NewDai(deploymentInfo.QuoteAddr, client.GetEthClient()) + if err != nil { + return nil, fmt.Errorf("could not create instance of quote token: %w", err) + } + + // deploy DAI tokens and derive their pair addresses on both factories pairInitCode := common.FromHex(contract.UniswapV2PairBin) pairInitHash := crypto.Keccak256(pairInitCode) - pairFundingAmount := uint256.NewInt(0) - var pairSalt [32]byte - for i := uint64(0); i < u.options.DaiPairs; i++ { - pairInfo := &PairDeploymentInfo{} + for i := uint64(0); i < u.options.PairCount; i++ { + pairInfo := PairDeploymentInfo{} // deploy Dai - pairInfo.DaiAddr, err = deployContract(contract.DaiMetaData, true, uint32(i), deployerWallet.GetChainId()) + pairInfo.DaiAddr, err = deployContract(contract.DaiMetaData, true, daiTokenSalt+uint32(i), deployerWallet.GetChainId()) if err != nil { return nil, fmt.Errorf("could not deploy Dai: %w", err) } @@ -199,152 +214,101 @@ func (u *Uniswap) DeployUniswapPairs(redeploy bool) (*DeploymentInfo, error) { return nil, fmt.Errorf("could not create instance of Dai: %w", err) } - // get pair on factory A - if pairInfo.DaiAddr.Big().Cmp(deploymentInfo.Weth9Addr.Big()) < 0 { - copy(pairSalt[:], crypto.Keccak256(pairInfo.DaiAddr.Bytes(), deploymentInfo.Weth9Addr.Bytes())) - } else { - copy(pairSalt[:], crypto.Keccak256(deploymentInfo.Weth9Addr.Bytes(), pairInfo.DaiAddr.Bytes())) - } - pairInfo.PairAddrA = crypto.CreateAddress2(deploymentInfo.UniswapFactoryAAddr, pairSalt, pairInitHash) + // pair on factory A + pairInfo.PairAddrA = v2PairAddress(deploymentInfo.UniswapFactoryAAddr, pairInfo.DaiAddr, deploymentInfo.QuoteAddr, pairInitHash) pairInfo.PairA, err = contract.NewUniswapV2Pair(pairInfo.PairAddrA, client.GetEthClient()) if err != nil { return nil, fmt.Errorf("could not create instance of uniswap v2 pair A: %w", err) } - // get pair on factory B - if pairInfo.DaiAddr.Big().Cmp(deploymentInfo.Weth9Addr.Big()) < 0 { - copy(pairSalt[:], crypto.Keccak256(pairInfo.DaiAddr.Bytes(), deploymentInfo.Weth9Addr.Bytes())) - } else { - copy(pairSalt[:], crypto.Keccak256(deploymentInfo.Weth9Addr.Bytes(), pairInfo.DaiAddr.Bytes())) - } - pairInfo.PairAddrB = crypto.CreateAddress2(deploymentInfo.UniswapFactoryBAddr, pairSalt, pairInitHash) + // pair on factory B + pairInfo.PairAddrB = v2PairAddress(deploymentInfo.UniswapFactoryBAddr, pairInfo.DaiAddr, deploymentInfo.QuoteAddr, pairInitHash) pairInfo.PairB, err = contract.NewUniswapV2Pair(pairInfo.PairAddrB, client.GetEthClient()) if err != nil { return nil, fmt.Errorf("could not create instance of uniswap v2 pair B: %w", err) } - deploymentInfo.Pairs = append(deploymentInfo.Pairs, *pairInfo) - - pairFundingAmount = pairFundingAmount.Add(pairFundingAmount, u.options.EthLiquidityPerPair) - fundingFees := uint256.NewInt(6000000) - fundingFees = fundingFees.Mul(fundingFees, uint256.MustFromBig(feeCap)) - pairFundingAmount = pairFundingAmount.Add(pairFundingAmount, fundingFees) + deploymentInfo.Pairs = append(deploymentInfo.Pairs, pairInfo) } // submit & await all deployment transactions - if len(deploymentTxs) > 0 { - _, err := u.walletPool.GetTxPool().SendTransactionBatch(u.ctx, deployerWallet, deploymentTxs, &spamoor.BatchOptions{ - SendTransactionOptions: spamoor.SendTransactionOptions{ - Client: client, - ClientGroup: u.options.ClientGroup, - }, - MaxRetries: 3, - PendingLimit: 10, - LogFn: func(confirmedCount int, totalCount int) { - u.logger.Infof("deploying contracts v2... (%v/%v)", confirmedCount, totalCount) - }, - LogInterval: 10, - }) - if err != nil { - return nil, fmt.Errorf("could not send deployment txs: %w", err) - } - u.logger.Infof("contract deployment complete. (%v/%v)", len(deploymentTxs), len(deploymentTxs)) + if err := u.sendBatch(deployerWallet, client, deploymentTxs, "deploying contracts v2"); err != nil { + return nil, err } - // Phase 2: post-deployment setup calls. Built only after the deployment - // batch has been mined so eth_estimateGas dispatches into the real - // contract code instead of treating the target as an EOA. - setupTxs := []*txtypes.Transaction{} + // Phase 2: seed liquidity into pairs that don't have any yet. Both tokens + // are minted by the liquidity provider, so the owner wallet only pays gas. + // Built only after the deployment batch has been mined so eth_estimateGas + // dispatches into the real contract code instead of treating the target as + // an EOA. callOpts := &bind.CallOpts{Context: u.ctx} + liquidityTxs := []*txtypes.Transaction{} + + // the liquidity provider splits the amounts evenly between both factories + quoteLiquidity := new(big.Int).Mul(u.options.QuoteLiquidityPerPool, big.NewInt(2)) + daiLiquidity := new(big.Int).Mul(quoteLiquidity, new(big.Int).SetUint64(u.options.TokensPerQuote)) for _, pairInfo := range deploymentInfo.Pairs { - // make liquidity provider a minter for the Dai - lpIsWard, err := pairInfo.Dai.Wards(callOpts, deploymentInfo.LiquidityProviderAddr) + seeded, err := v2PairHasLiquidity(callOpts, deploymentInfo.UniswapFactoryA, client, pairInfo.DaiAddr, deploymentInfo.QuoteAddr) if err != nil { - return nil, fmt.Errorf("could not check if liquidity provider is a ward for the Dai: %w", err) + return nil, err } - if lpIsWard.Cmp(big.NewInt(0)) == 0 { - tx, err := ownerWallet.BuildBoundTxWithEstimate(u.ctx, client, u.walletPool.GetTxPool(), &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return pairInfo.Dai.Rely(transactOpts, deploymentInfo.LiquidityProviderAddr) - }) - if err != nil { - return nil, fmt.Errorf("could not make liquidity provider a minter for the Dai: %w", err) - } - setupTxs = append(setupTxs, tx) + if seeded { + continue } - } - if len(setupTxs) > 0 { - _, err := u.walletPool.GetTxPool().SendTransactionBatch(u.ctx, ownerWallet, setupTxs, &spamoor.BatchOptions{ - SendTransactionOptions: spamoor.SendTransactionOptions{ - Client: client, - ClientGroup: u.options.ClientGroup, - }, - MaxRetries: 3, - PendingLimit: 10, - LogFn: func(confirmedCount int, totalCount int) { - u.logger.Infof("running post-deployment setup... (%v/%v)", confirmedCount, totalCount) - }, - LogInterval: 10, + daiAddr := pairInfo.DaiAddr + tx, err := ownerWallet.BuildBoundTxWithEstimate(u.ctx, client, u.walletPool.GetTxPool(), &txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Value: uint256.NewInt(0), + }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { + return deploymentInfo.LiquidityProvider.ProvidePairLiquidity(transactOpts, deploymentInfo.QuoteAddr, daiAddr, quoteLiquidity, daiLiquidity) }) if err != nil { - return nil, fmt.Errorf("could not send post-deployment setup txs: %w", err) + return nil, fmt.Errorf("could not provide liquidity for dai %v: %w", daiAddr.String(), err) } - u.logger.Infof("post-deployment setup complete. (%v/%v)", len(setupTxs), len(setupTxs)) + liquidityTxs = append(liquidityTxs, tx) } - // provide liquidity to the pairs - rootWallet := u.walletPool.GetRootWallet() - err = rootWallet.WithWalletLock(u.ctx, len(deploymentInfo.Pairs), pairFundingAmount, u.walletPool.GetClientPool(), func(reason string) { - u.logger.Infof("root wallet is locked, %s", reason) - }, func() error { - liquidityTxs := []*txtypes.Transaction{} - daiLiquidity := new(big.Int).Mul(u.options.EthLiquidityPerPair.ToBig(), big.NewInt(int64(u.options.DaiLiquidityFactor))) - - for _, pairInfo := range deploymentInfo.Pairs { - tx, err := rootWallet.GetWallet().BuildBoundTxWithEstimate(u.ctx, client, u.walletPool.GetTxPool(), &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Value: u.options.EthLiquidityPerPair, - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return deploymentInfo.LiquidityProvider.ProvidePairLiquidity(transactOpts, pairInfo.DaiAddr, daiLiquidity) - }) - if err != nil { - return fmt.Errorf("could not provide liquidity for dai %v: %w", pairInfo.DaiAddr.String(), err) - } - liquidityTxs = append(liquidityTxs, tx) - } + if err := u.sendBatch(ownerWallet, client, liquidityTxs, "providing liquidity"); err != nil { + return nil, err + } - // submit & await all liquidity txs - if len(liquidityTxs) > 0 { - _, err := u.walletPool.GetTxPool().SendTransactionBatch(u.ctx, rootWallet.GetWallet(), liquidityTxs, &spamoor.BatchOptions{ - SendTransactionOptions: spamoor.SendTransactionOptions{ - Client: client, - ClientGroup: u.options.ClientGroup, - }, - MaxRetries: 3, - PendingLimit: 10, - LogFn: func(confirmedCount int, totalCount int) { - u.logger.Infof("providing liquidity... (%v/%v)", confirmedCount, totalCount) - }, - LogInterval: 10, - }) - if err != nil { - return fmt.Errorf("could not send liquidity txs: %w", err) - } - - u.logger.Infof("liquidity provision complete. (%v/%v)", len(liquidityTxs), len(liquidityTxs)) - } + return deploymentInfo, nil +} - return nil - }) +// v2PairAddress computes the CREATE2 address of the tokenA/tokenB pair on the +// given factory, mirroring UniswapV2Library.pairFor. +func v2PairAddress(factory, tokenA, tokenB common.Address, pairInitHash []byte) common.Address { + token0, token1 := tokenA, tokenB + if token1.Big().Cmp(token0.Big()) < 0 { + token0, token1 = token1, token0 + } + var salt [32]byte + copy(salt[:], crypto.Keccak256(token0.Bytes(), token1.Bytes())) + return crypto.CreateAddress2(factory, salt, pairInitHash) +} + +// v2PairHasLiquidity reports whether the tokenA/tokenB pair exists on the +// factory and already holds reserves, in which case seeding is skipped so +// re-runs against an existing deployment don't double-seed. +func v2PairHasLiquidity(callOpts *bind.CallOpts, factory *contract.UniswapV2Factory, client *spamoor.Client, tokenA, tokenB common.Address) (bool, error) { + pairAddr, err := factory.GetPair(callOpts, tokenA, tokenB) if err != nil { - return nil, fmt.Errorf("could not provide liquidity: %w", err) + return false, fmt.Errorf("could not check pair existence: %w", err) + } + if pairAddr == (common.Address{}) { + return false, nil } - return deploymentInfo, nil + pair, err := contract.NewUniswapV2Pair(pairAddr, client.GetEthClient()) + if err != nil { + return false, fmt.Errorf("could not bind pair %v: %w", pairAddr.Hex(), err) + } + reserves, err := pair.GetReserves(callOpts) + if err != nil { + return false, fmt.Errorf("could not read pair reserves: %w", err) + } + return reserves.Reserve0.Sign() > 0 && reserves.Reserve1.Sign() > 0, nil } diff --git a/scenarios/uniswap-swaps/deployment_v3.go b/scenarios/uniswap-swaps/deployment_v3.go index 782ecd60..d31aa586 100644 --- a/scenarios/uniswap-swaps/deployment_v3.go +++ b/scenarios/uniswap-swaps/deployment_v3.go @@ -19,11 +19,14 @@ import ( // V3DeploymentInfo holds the deployed Uniswap v3 contract set for the scenario. // Two factories (each with its own SwapRouter) are deployed so that every DAI -// instance gets a separate pool per factory at the same fee tier, mirroring the -// two-factory layout of the v2 path. +// token gets a separate pool with the shared quote token per factory at the +// same fee tier, mirroring the two-factory layout of the v2 path. type V3DeploymentInfo struct { + // Weth9Addr is only needed because the canonical SwapRouter requires a WETH + // address at construction; the scenario never trades ETH/WETH. Weth9Addr common.Address - Weth9 *contract.WETH9 + QuoteAddr common.Address + Quote *contract.Dai FactoryAAddr common.Address FactoryA *contract.UniswapV3Factory FactoryBAddr common.Address @@ -40,24 +43,19 @@ type V3DeploymentInfo struct { } type V3PoolDeploymentInfo struct { - DaiAddr common.Address - Dai *contract.Dai - WethIsToken0 bool - PoolAAddr common.Address - PoolA *contract.UniswapV3Pool - PoolBAddr common.Address - PoolB *contract.UniswapV3Pool + DaiAddr common.Address + Dai *contract.Dai + QuoteIsToken0 bool + PoolAAddr common.Address + PoolA *contract.UniswapV3Pool + PoolBAddr common.Address + PoolB *contract.UniswapV3Pool } -// liquidityBudgetBps applies a small safety margin (0.1%) to the WETH liquidity -// budget when sizing the position, so the pool's round-up of owed amounts can -// never exceed the ETH value forwarded to the liquidity provider. -const liquidityBudgetBps = 9990 - // DeployUniswapV3 deploys two canonical Uniswap v3 factories + SwapRouters, the -// custom liquidity provider, and one DAI token per configured pair. Each DAI -// gets a pool on both factories, which are then initialized and seeded with a -// full-range position. +// custom liquidity provider, the shared quote token and one DAI token per +// configured pair. Each DAI gets a pool on both factories, which are then +// initialized and seeded with a full-range position. func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { client := u.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), @@ -72,7 +70,6 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { if deployerWallet == nil || ownerWallet == nil { return nil, scenario.ErrNoWallet } - rootAddr := u.walletPool.GetRootWallet().GetWallet().GetAddress() deployerSeed := [32]byte{} copy(deployerSeed[:], deployerWallet.GetAddress().Bytes()) @@ -121,15 +118,11 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { Fee: new(big.Int).SetUint64(u.options.FeeTier), } - // deploy WETH9 + // deploy WETH9 (SwapRouter constructor dependency only) info.Weth9Addr, err = deployContract(contract.WETH9MetaData, true, 0) if err != nil { return nil, fmt.Errorf("could not deploy WETH9: %w", err) } - info.Weth9, err = contract.NewWETH9(info.Weth9Addr, client.GetEthClient()) - if err != nil { - return nil, fmt.Errorf("could not create instance of WETH9: %w", err) - } // deploy two v3 factories (identical bytecode -> distinct salts) info.FactoryAAddr, err = deployContract(contract.UniswapV3FactoryMetaData, false, 0) @@ -169,8 +162,8 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { return nil, fmt.Errorf("could not create instance of swap router B: %w", err) } - // deploy liquidity provider helper - info.LiquidityProviderAddr, err = deployContract(contract.V3LiquidityProviderMetaData, false, 0, ownerWallet.GetAddress(), rootAddr, info.Weth9Addr) + // deploy liquidity provider helper (owner-only, mints both tokens on demand) + info.LiquidityProviderAddr, err = deployContract(contract.V3LiquidityProviderMetaData, false, 0, ownerWallet.GetAddress()) if err != nil { return nil, fmt.Errorf("could not deploy v3 liquidity provider: %w", err) } @@ -179,10 +172,20 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { return nil, fmt.Errorf("could not create instance of v3 liquidity provider: %w", err) } + // deploy the shared quote token + info.QuoteAddr, err = deployContract(contract.DaiMetaData, true, quoteTokenSalt, deployerWallet.GetChainId()) + if err != nil { + return nil, fmt.Errorf("could not deploy quote token: %w", err) + } + info.Quote, err = contract.NewDai(info.QuoteAddr, client.GetEthClient()) + if err != nil { + return nil, fmt.Errorf("could not create instance of quote token: %w", err) + } + // deploy DAI tokens (one per pair) - for i := uint64(0); i < u.options.DaiPairs; i++ { + for i := uint64(0); i < u.options.PairCount; i++ { poolInfo := V3PoolDeploymentInfo{} - poolInfo.DaiAddr, err = deployContract(contract.DaiMetaData, true, uint32(i), deployerWallet.GetChainId()) + poolInfo.DaiAddr, err = deployContract(contract.DaiMetaData, true, daiTokenSalt+uint32(i), deployerWallet.GetChainId()) if err != nil { return nil, fmt.Errorf("could not deploy Dai: %w", err) } @@ -190,7 +193,7 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { if err != nil { return nil, fmt.Errorf("could not create instance of Dai: %w", err) } - poolInfo.WethIsToken0 = info.Weth9Addr.Big().Cmp(poolInfo.DaiAddr.Big()) < 0 + poolInfo.QuoteIsToken0 = info.QuoteAddr.Big().Cmp(poolInfo.DaiAddr.Big()) < 0 info.Pools = append(info.Pools, poolInfo) } @@ -215,7 +218,7 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { for i := range info.Pools { dai := info.Pools[i].DaiAddr for _, factory := range []*contract.UniswapV3Factory{info.FactoryA, info.FactoryB} { - poolAddr, err := factory.GetPool(callOpts, dai, info.Weth9Addr, info.Fee) + poolAddr, err := factory.GetPool(callOpts, dai, info.QuoteAddr, info.Fee) if err != nil { return nil, fmt.Errorf("could not check pool existence: %w", err) } @@ -228,7 +231,7 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { GasTipCap: uint256.MustFromBig(tipCap), Value: uint256.NewInt(0), }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return factory.CreatePool(transactOpts, dai, info.Weth9Addr, info.Fee) + return factory.CreatePool(transactOpts, dai, info.QuoteAddr, info.Fee) }) if err != nil { return nil, fmt.Errorf("could not create pool: %w", err) @@ -244,11 +247,11 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { for i := range info.Pools { dai := info.Pools[i].DaiAddr - poolAAddr, err := info.FactoryA.GetPool(callOpts, dai, info.Weth9Addr, info.Fee) + poolAAddr, err := info.FactoryA.GetPool(callOpts, dai, info.QuoteAddr, info.Fee) if err != nil { return nil, fmt.Errorf("could not read pool A address: %w", err) } - poolBAddr, err := info.FactoryB.GetPool(callOpts, dai, info.Weth9Addr, info.Fee) + poolBAddr, err := info.FactoryB.GetPool(callOpts, dai, info.QuoteAddr, info.Fee) if err != nil { return nil, fmt.Errorf("could not read pool B address: %w", err) } @@ -268,11 +271,11 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { } } - // Phase 3: initialize pools and grant the liquidity provider mint rights. + // Phase 3: initialize pools that have no price yet. setupTxs := []*txtypes.Transaction{} for i := range info.Pools { poolInfo := info.Pools[i] - sqrtPriceX96 := u.v3SqrtPriceX96(poolInfo.WethIsToken0) + sqrtPriceX96 := u.v3SqrtPriceX96(poolInfo.QuoteIsToken0) for _, pool := range []*contract.UniswapV3Pool{poolInfo.PoolA, poolInfo.PoolB} { slot0, err := pool.Slot0(callOpts) @@ -301,7 +304,7 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { } // Phase 4: seed full-range liquidity into every pool. - if err := u.provideV3Liquidity(info, client, feeCap, tipCap); err != nil { + if err := u.provideV3Liquidity(info, client, ownerWallet, feeCap, tipCap); err != nil { return nil, err } @@ -311,21 +314,10 @@ func (u *Uniswap) DeployUniswapV3() (*V3DeploymentInfo, error) { // InitializeContractsV3 binds the deployed v3 contract instances to the static // call client and stores the deployment for the swap phase. func (u *Uniswap) InitializeContractsV3(info *V3DeploymentInfo) error { - client := u.walletPool.GetClient( - spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), - spamoor.WithoutBuilder(), // avoid using builders for eth_calls - ) - if client == nil { - return scenario.ErrNoClients - } - - u.logger.Infof("Using client for static calls: %s", client.GetName()) - - weth, err := contract.NewWETH9(info.Weth9Addr, client.GetEthClient()) + client, err := u.staticCallClient() if err != nil { - return fmt.Errorf("could not initialize WETH9: %w", err) + return err } - u.Weth = weth info.RouterA, err = contract.NewSwapRouter(info.RouterAAddr, client.GetEthClient()) if err != nil { @@ -335,85 +327,84 @@ func (u *Uniswap) InitializeContractsV3(info *V3DeploymentInfo) error { if err != nil { return fmt.Errorf("could not initialize swap router B: %w", err) } - - u.Tokens = make(map[common.Address]*contract.Dai, len(info.Pools)) - for _, poolInfo := range info.Pools { - token, err := contract.NewDai(poolInfo.DaiAddr, client.GetEthClient()) + for i := range info.Pools { + info.Pools[i].PoolA, err = contract.NewUniswapV3Pool(info.Pools[i].PoolAAddr, client.GetEthClient()) if err != nil { - return fmt.Errorf("could not initialize token %v: %w", poolInfo.DaiAddr, err) + return fmt.Errorf("could not initialize pool A: %w", err) + } + info.Pools[i].PoolB, err = contract.NewUniswapV3Pool(info.Pools[i].PoolBAddr, client.GetEthClient()) + if err != nil { + return fmt.Errorf("could not initialize pool B: %w", err) } - u.Tokens[poolInfo.DaiAddr] = token + } + u.Quote, err = contract.NewDai(info.QuoteAddr, client.GetEthClient()) + if err != nil { + return fmt.Errorf("could not initialize quote token: %w", err) } u.v3Deployment = info return nil } -// v3SqrtPriceX96 returns the starting price for a pool, derived from the desired -// DAI/WETH reserve ratio (the same ratio the v2 path uses for liquidity depth). -func (u *Uniswap) v3SqrtPriceX96(wethIsToken0 bool) *big.Int { - wethReserve := u.options.EthLiquidityPerPair.ToBig() - daiReserve := new(big.Int).Mul(wethReserve, new(big.Int).SetUint64(u.options.DaiLiquidityFactor)) +// v3SqrtPriceX96 returns the starting price for a pool, derived from the +// seeded DAI/quote reserve ratio (TokensPerQuote DAI per quote token). +func (u *Uniswap) v3SqrtPriceX96(quoteIsToken0 bool) *big.Int { + quoteReserve := u.options.QuoteLiquidityPerPool + daiReserve := new(big.Int).Mul(quoteReserve, new(big.Int).SetUint64(u.options.TokensPerQuote)) - if wethIsToken0 { - // token0 = WETH, token1 = DAI -> price = DAI/WETH - return encodeSqrtRatioX96(daiReserve, wethReserve) + if quoteIsToken0 { + // token0 = quote, token1 = DAI -> price = DAI/quote + return encodeSqrtRatioX96(daiReserve, quoteReserve) } - // token0 = DAI, token1 = WETH -> price = WETH/DAI - return encodeSqrtRatioX96(wethReserve, daiReserve) + // token0 = DAI, token1 = quote -> price = quote/DAI + return encodeSqrtRatioX96(quoteReserve, daiReserve) } -// provideV3Liquidity seeds a full-range position into every pool from the root -// wallet, forwarding ETH for the WETH side while DAI is minted on demand. -func (u *Uniswap) provideV3Liquidity(info *V3DeploymentInfo, client *spamoor.Client, feeCap, tipCap *big.Int) error { +// provideV3Liquidity seeds a full-range position into every pool that has no +// liquidity yet. Both tokens are minted on demand by the liquidity provider, so +// the owner wallet only pays gas. +func (u *Uniswap) provideV3Liquidity(info *V3DeploymentInfo, client *spamoor.Client, ownerWallet *spamoor.Wallet, feeCap, tipCap *big.Int) error { tickLower, tickUpper := fullRangeTicks(info.TickSpacing) + callOpts := &bind.CallOpts{Context: u.ctx} + liquidityTxs := []*txtypes.Transaction{} - // each DAI has a pool on both factories -> two liquidity txs per DAI. - poolCount := len(info.Pools) * 2 - - pairFundingAmount := uint256.NewInt(0) - for i := 0; i < poolCount; i++ { - pairFundingAmount = pairFundingAmount.Add(pairFundingAmount, u.options.EthLiquidityPerPair) - fundingFees := uint256.NewInt(6000000) - fundingFees = fundingFees.Mul(fundingFees, uint256.MustFromBig(feeCap)) - pairFundingAmount = pairFundingAmount.Add(pairFundingAmount, fundingFees) - } - - rootWallet := u.walletPool.GetRootWallet() - return rootWallet.WithWalletLock(u.ctx, poolCount, pairFundingAmount, u.walletPool.GetClientPool(), func(reason string) { - u.logger.Infof("root wallet is locked, %s", reason) - }, func() error { - liquidityTxs := []*txtypes.Transaction{} - - // WETH budget bounds the seeded liquidity; DAI is minted on demand. - wethBudget := new(big.Int).Div( - new(big.Int).Mul(u.options.EthLiquidityPerPair.ToBig(), big.NewInt(liquidityBudgetBps)), - big.NewInt(10000), - ) - - for i := range info.Pools { - poolInfo := info.Pools[i] - sqrtPriceX96 := u.v3SqrtPriceX96(poolInfo.WethIsToken0) - liquidity := fullRangeLiquidityForWeth(sqrtPriceX96, poolInfo.WethIsToken0, wethBudget) - - for _, poolAddr := range []common.Address{poolInfo.PoolAAddr, poolInfo.PoolBAddr} { - poolAddr := poolAddr - tx, err := rootWallet.GetWallet().BuildBoundTxWithEstimate(u.ctx, client, u.walletPool.GetTxPool(), &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Value: u.options.EthLiquidityPerPair, - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return info.LiquidityProvider.ProvideLiquidity(transactOpts, poolAddr, tickLower, tickUpper, liquidity) - }) - if err != nil { - return fmt.Errorf("could not provide liquidity for pool %v: %w", poolAddr.Hex(), err) - } - liquidityTxs = append(liquidityTxs, tx) + for i := range info.Pools { + poolInfo := info.Pools[i] + sqrtPriceX96 := u.v3SqrtPriceX96(poolInfo.QuoteIsToken0) + liquidity := fullRangeLiquidityForToken(sqrtPriceX96, poolInfo.QuoteIsToken0, u.options.QuoteLiquidityPerPool) + + pools := []struct { + addr common.Address + pool *contract.UniswapV3Pool + }{ + {poolInfo.PoolAAddr, poolInfo.PoolA}, + {poolInfo.PoolBAddr, poolInfo.PoolB}, + } + for _, p := range pools { + existing, err := p.pool.Liquidity(callOpts) + if err != nil { + return fmt.Errorf("could not read pool liquidity: %w", err) + } + if existing.Sign() > 0 { + continue } + + poolAddr := p.addr + tx, err := ownerWallet.BuildBoundTxWithEstimate(u.ctx, client, u.walletPool.GetTxPool(), &txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Value: uint256.NewInt(0), + }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { + return info.LiquidityProvider.ProvideLiquidity(transactOpts, poolAddr, tickLower, tickUpper, liquidity) + }) + if err != nil { + return fmt.Errorf("could not provide liquidity for pool %v: %w", poolAddr.Hex(), err) + } + liquidityTxs = append(liquidityTxs, tx) } + } - return u.sendBatch(rootWallet.GetWallet(), client, liquidityTxs, "providing liquidity") - }) + return u.sendBatch(ownerWallet, client, liquidityTxs, "providing liquidity") } // sendBatch submits a batch of transactions from a single wallet and waits for diff --git a/scenarios/uniswap-swaps/swap_v2.go b/scenarios/uniswap-swaps/swap_v2.go index f241a12c..960d7d68 100644 --- a/scenarios/uniswap-swaps/swap_v2.go +++ b/scenarios/uniswap-swaps/swap_v2.go @@ -2,7 +2,6 @@ package uniswapswaps import ( "context" - "fmt" "math/big" mathrand "math/rand" "time" @@ -17,241 +16,85 @@ import ( "github.com/ethpandaops/spamoor/txtypes" ) -// buildV2SwapTx builds a single uniswap v2 swap against a randomly selected pair -// and router, deciding buy vs sell from the configured ratio and the wallet's -// tracked balances. Amounts are DAI-denominated and priced via the router's -// on-chain getAmountsIn/getAmountsOut helpers. +// buildV2SwapTx builds a single uniswap v2 swap against a randomly selected +// DAI/quote pair and router, deciding buy vs sell from the configured ratio and +// the wallet's tracked balances. Amounts are DAI-denominated and priced via the +// router's on-chain getAmountsIn/getAmountsOut helpers. A wallet that cannot +// afford a buy mints itself more quote tokens instead of swapping. func (s *Scenario) buildV2SwapTx(ctx context.Context, wallet *spamoor.Wallet, feeCap, tipCap *big.Int) (*txtypes.Transaction, error) { - // Select random pair - pairIdx := mathrand.Intn(len(s.deploymentInfo.Pairs)) - pair := s.deploymentInfo.Pairs[pairIdx] + info := s.deploymentInfo + pair := info.Pairs[mathrand.Intn(len(info.Pairs))] + daiAddr := pair.DaiAddr + quoteAddr := info.QuoteAddr - // Parse min and max swap amounts - minAmount, ok := new(big.Int).SetString(s.options.MinSwapAmount, 10) - if !ok { - return nil, fmt.Errorf("invalid min swap amount: %s", s.options.MinSwapAmount) - } - - maxAmount, ok := new(big.Int).SetString(s.options.MaxSwapAmount, 10) - if !ok { - return nil, fmt.Errorf("invalid max swap amount: %s", s.options.MaxSwapAmount) - } - - // Calculate random swap amount - diff := new(big.Int).Sub(maxAmount, minAmount) - randomAmount := new(big.Int).Add(minAmount, new(big.Int).Rand(mathrand.New(mathrand.NewSource(time.Now().UnixNano())), diff)) - - // Per-trade slippage tolerance (fixed --slippage or a random draw from - // the configured [slippage_min, slippage_max] band). - slippage := s.perTradeSlippage() - - // Get current token balance from cache - tokenBalance := s.uniswap.GetTokenBalance(wallet.GetAddress(), pair.DaiAddr) - - // Get current ETH balance from wallet - ethBalance := wallet.GetBalance() - - // Get current WETH balance - wethBalance := s.uniswap.GetTokenBalance(wallet.GetAddress(), s.deploymentInfo.Weth9Addr) - - // Decide if we're buying or selling based on buy ratio and balances - isBuy := mathrand.Intn(100) < int(s.options.BuyRatio) - - // Parse sell threshold - sellThreshold, ok := new(big.Int).SetString(s.options.SellThreshold, 10) - if !ok { - return nil, fmt.Errorf("invalid sell threshold: %s", s.options.SellThreshold) - } - - // If we have a lot of DAI, force a sell to avoid depleting the pool - if tokenBalance.Cmp(sellThreshold) > 0 { - isBuy = false - } - - // If we don't have enough DAI to sell, switch to buy - if !isBuy && tokenBalance.Cmp(randomAmount) < 0 { - isBuy = true - } - - // Alternate between routers based on transaction index + // alternate between the two routers (factory A vs B pair) router := s.uniswap.RouterA if mathrand.Intn(100) < 50 { router = s.uniswap.RouterB } - var tx *txtypes.Transaction - - if isBuy { - // Decide whether to use ETH or WETH for buying - useWeth := mathrand.Intn(100) < 60 // 60% chance to use WETH if available - - if useWeth { - // Buying DAI with WETH - // Calculate how much WETH we need to spend to get the desired amount of DAI - amounts, err := router.GetAmountsIn(&bind.CallOpts{}, randomAmount, []common.Address{s.deploymentInfo.Weth9Addr, pair.DaiAddr}) - if err != nil { - return nil, err - } - - wethAmount := amounts[0] - - // Check if we have enough WETH - if wethBalance.Cmp(wethAmount) < 0 { - // Fall back to ETH if not enough WETH - useWeth = false - } else { - // Calculate minimum DAI amount to receive (with slippage) - minDaiAmount := new(big.Int).Mul(randomAmount, big.NewInt(10000-int64(slippage))) - minDaiAmount = minDaiAmount.Div(minDaiAmount, big.NewInt(10000)) - - // Build buy transaction with WETH - tx, err = wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.SwapExactTokensForTokens(transactOpts, wethAmount, minDaiAmount, []common.Address{s.deploymentInfo.Weth9Addr, pair.DaiAddr}, wallet.GetAddress(), big.NewInt(time.Now().Unix()+300)) - }) - if err != nil { - return nil, err - } + amount := s.randomSwapAmount() + slippage := s.perTradeSlippage() - // Update balances in local cache - if tx != nil { - // Subtract WETH amount - newWethBalance := new(big.Int).Sub(wethBalance, wethAmount) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), s.deploymentInfo.Weth9Addr, newWethBalance) + addr := wallet.GetAddress() + daiBalance := s.uniswap.GetTokenBalance(addr, daiAddr) + quoteBalance := s.uniswap.GetTokenBalance(addr, quoteAddr) + isBuy := s.decideSwapSide(daiBalance, amount) + + callOpts := &bind.CallOpts{Context: ctx} + deadline := big.NewInt(time.Now().Unix() + 300) + + buildSwap := func(amountIn, amountOutMin *big.Int, path []common.Address) (*txtypes.Transaction, error) { + return wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: swapGasLimit, + Value: uint256.NewInt(0), + }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { + return router.SwapExactTokensForTokens(transactOpts, amountIn, amountOutMin, path, addr, deadline) + }) + } - // Add DAI amount - newDaiBalance := new(big.Int).Add(tokenBalance, randomAmount) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), pair.DaiAddr, newDaiBalance) - } - } + if isBuy { + // Buying DAI with quote tokens: quote the input needed for the desired + // DAI amount, then swap exact input with a slippage-adjusted floor. + path := []common.Address{quoteAddr, daiAddr} + amounts, err := router.GetAmountsIn(callOpts, amount, path) + if err != nil { + return nil, err } + quoteIn := amounts[0] - // If not using WETH or not enough WETH, use ETH - if !useWeth { - // Buying DAI with ETH - // Calculate how much ETH we need to spend to get the desired amount of DAI - amounts, err := router.GetAmountsIn(&bind.CallOpts{}, randomAmount, []common.Address{s.deploymentInfo.Weth9Addr, pair.DaiAddr}) - if err != nil { - return nil, err - } - - ethAmount := amounts[0] - - // Check if we have enough ETH - if ethBalance.Cmp(ethAmount) < 0 { - return nil, fmt.Errorf("insufficient ETH balance for swap") - } - - // Calculate minimum DAI amount to receive (with slippage) - minDaiAmount := new(big.Int).Mul(randomAmount, big.NewInt(10000-int64(slippage))) - minDaiAmount = minDaiAmount.Div(minDaiAmount, big.NewInt(10000)) - - // Build buy transaction - tx, err = wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.MustFromBig(ethAmount), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.SwapExactETHForTokens(transactOpts, minDaiAmount, []common.Address{s.deploymentInfo.Weth9Addr, pair.DaiAddr}, wallet.GetAddress(), big.NewInt(time.Now().Unix()+300)) - }) - if err != nil { - return nil, err - } - - // Update balances in local cache - if tx != nil { - // Subtract ETH amount - wallet.SubBalance(ethAmount) - - // Add DAI amount - newDaiBalance := new(big.Int).Add(tokenBalance, randomAmount) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), pair.DaiAddr, newDaiBalance) - } + if quoteBalance.Cmp(quoteIn) < 0 { + // out of quote tokens: top up instead of swapping this round + s.logger.WithField("wallet", s.walletPool.GetWalletName(addr)).Debugf("quote balance %s below %s needed for buy, minting funding", quoteBalance, quoteIn) + return s.uniswap.buildQuoteMintTx(ctx, wallet, feeCap, tipCap) } - } else { - // Decide whether to keep WETH or convert to ETH - keepWeth := mathrand.Intn(100) < 30 // 30% chance to keep WETH - - if keepWeth { - // Selling DAI for WETH - if tokenBalance.Cmp(randomAmount) < 0 { - return nil, fmt.Errorf("insufficient DAI balance for swap") - } - - // Calculate minimum WETH amount to receive (with slippage) - amounts, err := router.GetAmountsOut(&bind.CallOpts{}, randomAmount, []common.Address{pair.DaiAddr, s.deploymentInfo.Weth9Addr}) - if err != nil { - return nil, err - } - minWethAmount := new(big.Int).Mul(amounts[1], big.NewInt(10000-int64(slippage))) - minWethAmount = minWethAmount.Div(minWethAmount, big.NewInt(10000)) - - // Build sell transaction for WETH - tx, err = wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.SwapExactTokensForTokens(transactOpts, randomAmount, minWethAmount, []common.Address{pair.DaiAddr, s.deploymentInfo.Weth9Addr}, wallet.GetAddress(), big.NewInt(time.Now().Unix()+300)) - }) - if err != nil { - return nil, err - } - - // Update balances in local cache - if tx != nil { - // Subtract DAI amount - newDaiBalance := new(big.Int).Sub(tokenBalance, randomAmount) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), pair.DaiAddr, newDaiBalance) - // Add WETH amount - newWethBalance := new(big.Int).Add(wethBalance, amounts[1]) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), s.deploymentInfo.Weth9Addr, newWethBalance) - } - } else { - // Selling DAI for ETH - if tokenBalance.Cmp(randomAmount) < 0 { - return nil, fmt.Errorf("insufficient DAI balance for swap") - } - - // Calculate minimum ETH amount to receive (with slippage) - amounts, err := router.GetAmountsOut(&bind.CallOpts{}, randomAmount, []common.Address{pair.DaiAddr, s.deploymentInfo.Weth9Addr}) - if err != nil { - return nil, err - } - minEthAmount := new(big.Int).Mul(amounts[1], big.NewInt(10000-int64(slippage))) - minEthAmount = minEthAmount.Div(minEthAmount, big.NewInt(10000)) + tx, err := buildSwap(quoteIn, applySlippage(amount, slippage), path) + if err != nil { + return nil, err + } - // Build sell transaction - tx, err = wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.SwapExactTokensForETH(transactOpts, randomAmount, minEthAmount, []common.Address{pair.DaiAddr, s.deploymentInfo.Weth9Addr}, wallet.GetAddress(), big.NewInt(time.Now().Unix()+300)) - }) - if err != nil { - return nil, err - } + s.uniswap.UpdateTokenBalance(addr, quoteAddr, new(big.Int).Sub(quoteBalance, quoteIn)) + s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Add(daiBalance, amount)) + return tx, nil + } - // Update balances in local cache - if tx != nil { - // Subtract DAI amount - newDaiBalance := new(big.Int).Sub(tokenBalance, randomAmount) - s.uniswap.UpdateTokenBalance(wallet.GetAddress(), pair.DaiAddr, newDaiBalance) + // Selling DAI for quote tokens. + path := []common.Address{daiAddr, quoteAddr} + amounts, err := router.GetAmountsOut(callOpts, amount, path) + if err != nil { + return nil, err + } + quoteOut := amounts[1] - // Add ETH amount - wallet.AddBalance(amounts[1]) - } - } + tx, err := buildSwap(amount, applySlippage(quoteOut, slippage), path) + if err != nil { + return nil, err } + s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Sub(daiBalance, amount)) + s.uniswap.UpdateTokenBalance(addr, quoteAddr, new(big.Int).Add(quoteBalance, quoteOut)) return tx, nil } diff --git a/scenarios/uniswap-swaps/swap_v3.go b/scenarios/uniswap-swaps/swap_v3.go index 9907dd37..b2c758fe 100644 --- a/scenarios/uniswap-swaps/swap_v3.go +++ b/scenarios/uniswap-swaps/swap_v3.go @@ -19,11 +19,12 @@ import ( ) // buildV3SwapTx builds a single exact-input swap against a randomly selected -// pool, routed through one of the two SwapRouters (which picks the matching -// factory's pool). Swap sizes are DAI-denominated to match the v2 path. There -// is no quoter deployed, so amountOutMinimum is derived from the pool's current -// spot price with the configured slippage tolerance applied, while balances are -// tracked with a conservative estimate to avoid insufficient-input reverts. +// DAI/quote pool, routed through one of the two SwapRouters (which picks the +// matching factory's pool). Swap sizes are DAI-denominated to match the v2 +// path. There is no quoter deployed, so both the required input and the +// amountOutMinimum are derived from the pool's current spot price with the pool +// fee and the configured slippage tolerance applied. A wallet that cannot +// afford a buy mints itself more quote tokens instead of swapping. func (s *Scenario) buildV3SwapTx(ctx context.Context, wallet *spamoor.Wallet, feeCap, tipCap *big.Int) (*txtypes.Transaction, error) { info := s.uniswap.v3Deployment if info == nil || len(info.Pools) == 0 { @@ -32,7 +33,7 @@ func (s *Scenario) buildV3SwapTx(ctx context.Context, wallet *spamoor.Wallet, fe poolInfo := info.Pools[mathrand.Intn(len(info.Pools))] daiAddr := poolInfo.DaiAddr - wethAddr := info.Weth9Addr + quoteAddr := info.QuoteAddr // alternate between the two routers (factory A vs B pool) router := info.RouterA @@ -42,69 +43,27 @@ func (s *Scenario) buildV3SwapTx(ctx context.Context, wallet *spamoor.Wallet, fe pool = poolInfo.PoolB } - minAmount, ok := new(big.Int).SetString(s.options.MinSwapAmount, 10) - if !ok { - return nil, fmt.Errorf("invalid min swap amount: %s", s.options.MinSwapAmount) - } - maxAmount, ok := new(big.Int).SetString(s.options.MaxSwapAmount, 10) - if !ok { - return nil, fmt.Errorf("invalid max swap amount: %s", s.options.MaxSwapAmount) - } - sellThreshold, ok := new(big.Int).SetString(s.options.SellThreshold, 10) - if !ok { - return nil, fmt.Errorf("invalid sell threshold: %s", s.options.SellThreshold) - } - - diff := new(big.Int).Sub(maxAmount, minAmount) - randomAmount := new(big.Int).Add(minAmount, new(big.Int).Rand(mathrand.New(mathrand.NewSource(time.Now().UnixNano())), diff)) - - priceFactor := new(big.Int).SetUint64(s.uniswap.options.DaiLiquidityFactor) - if priceFactor.Sign() == 0 { - priceFactor = big.NewInt(1) - } + amount := s.randomSwapAmount() + slippage := s.perTradeSlippage() addr := wallet.GetAddress() daiBalance := s.uniswap.GetTokenBalance(addr, daiAddr) - wethBalance := s.uniswap.GetTokenBalance(addr, wethAddr) - ethBalance := wallet.GetBalance() - - isBuy := mathrand.Intn(100) < int(s.options.BuyRatio) - if daiBalance.Cmp(sellThreshold) > 0 { - // too much DAI accumulated, force a sell to avoid depleting balances - isBuy = false - } - if !isBuy && daiBalance.Cmp(randomAmount) < 0 { - // not enough DAI to sell, switch to buy - isBuy = true - } + quoteBalance := s.uniswap.GetTokenBalance(addr, quoteAddr) + isBuy := s.decideSwapSide(daiBalance, amount) - // Per-trade slippage tolerance (fixed --slippage or a random draw from - // the configured [slippage_min, slippage_max] band). - slippage := s.perTradeSlippage() - - // There is no quoter contract deployed, so the output floor is derived from - // the routed pool's current spot price: expected output net of the pool fee - // (which v3 takes on the input), reduced by the slippage tolerance. Price - // impact and price movement until execution must fit into the tolerance, - // matching how the v2 path quotes before applying it. - minAmountOut := func(tokenIn common.Address, amountIn *big.Int) (*big.Int, error) { - slot0, err := pool.Slot0(&bind.CallOpts{}) - if err != nil { - return nil, fmt.Errorf("could not read pool slot0: %w", err) - } - - feeDenom := big.NewInt(1_000_000) - amountInAfterFee := new(big.Int).Div(new(big.Int).Mul(amountIn, new(big.Int).Sub(feeDenom, info.Fee)), feeDenom) - zeroForOne := (tokenIn == wethAddr) == poolInfo.WethIsToken0 - expectedOut := spotAmountOut(slot0.SqrtPriceX96, amountInAfterFee, zeroForOne) - - minOut := new(big.Int).Mul(expectedOut, big.NewInt(10000-int64(slippage))) - return minOut.Div(minOut, big.NewInt(10000)), nil + // Spot price of the routed pool. Price impact and price movement until + // execution must fit into the slippage tolerance, matching how the v2 path + // quotes before applying it. + slot0, err := pool.Slot0(&bind.CallOpts{Context: ctx}) + if err != nil { + return nil, fmt.Errorf("could not read pool slot0: %w", err) } + // zeroForOne for a swap that spends quote and receives DAI + quoteForDai := poolInfo.QuoteIsToken0 deadline := big.NewInt(time.Now().Unix() + 300) - mkParams := func(tokenIn, tokenOut common.Address, amountIn, amountOutMinimum *big.Int) contract.ISwapRouterExactInputSingleParams { - return contract.ISwapRouterExactInputSingleParams{ + buildSwap := func(tokenIn, tokenOut common.Address, amountIn, amountOutMinimum *big.Int) (*txtypes.Transaction, error) { + params := contract.ISwapRouterExactInputSingleParams{ TokenIn: tokenIn, TokenOut: tokenOut, Fee: info.Fee, @@ -114,82 +73,47 @@ func (s *Scenario) buildV3SwapTx(ctx context.Context, wallet *spamoor.Wallet, fe AmountOutMinimum: amountOutMinimum, SqrtPriceLimitX96: big.NewInt(0), } - } - - if isBuy { - // WETH input needed to buy ~randomAmount DAI worth of tokens. - wethIn := new(big.Int).Div(randomAmount, priceFactor) - if wethIn.Sign() == 0 { - wethIn = big.NewInt(1) - } - // conservative DAI output estimate (minus fee/slippage headroom). - daiOutEst := new(big.Int).Div(new(big.Int).Mul(randomAmount, big.NewInt(95)), big.NewInt(100)) - minDaiOut, err := minAmountOut(wethAddr, wethIn) - if err != nil { - return nil, err - } - params := mkParams(wethAddr, daiAddr, wethIn, minDaiOut) - - // prefer spending held WETH when available, otherwise pay with ETH. - if mathrand.Intn(100) < 60 && wethBalance.Cmp(wethIn) >= 0 { - tx, err := wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.ExactInputSingle(transactOpts, params) - }) - if err != nil { - return nil, err - } - s.uniswap.UpdateTokenBalance(addr, wethAddr, new(big.Int).Sub(wethBalance, wethIn)) - s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Add(daiBalance, daiOutEst)) - return tx, nil - } - - // pay with raw ETH (SwapRouter wraps msg.value when tokenIn is WETH). - if ethBalance.Cmp(wethIn) < 0 { - return nil, fmt.Errorf("insufficient ETH balance for swap") - } - tx, err := wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ + return wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ GasFeeCap: uint256.MustFromBig(feeCap), GasTipCap: uint256.MustFromBig(tipCap), Gas: swapGasLimit, - Value: uint256.MustFromBig(wethIn), + Value: uint256.NewInt(0), }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { return router.ExactInputSingle(transactOpts, params) }) + } + + if isBuy { + // Buying DAI with quote tokens: quote input needed for the desired DAI + // amount at spot (grossed up for the pool fee), floor = spot output of + // that input minus slippage. + quoteIn := spotAmountIn(slot0.SqrtPriceX96, info.Fee, amount, quoteForDai) + if quoteBalance.Cmp(quoteIn) < 0 { + // out of quote tokens: top up instead of swapping this round + s.logger.WithField("wallet", s.walletPool.GetWalletName(addr)).Debugf("quote balance %s below %s needed for buy, minting funding", quoteBalance, quoteIn) + return s.uniswap.buildQuoteMintTx(ctx, wallet, feeCap, tipCap) + } + + minDaiOut := applySlippage(spotAmountOutAfterFee(slot0.SqrtPriceX96, info.Fee, quoteIn, quoteForDai), slippage) + tx, err := buildSwap(quoteAddr, daiAddr, quoteIn, minDaiOut) if err != nil { return nil, err } - wallet.SubBalance(wethIn) - s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Add(daiBalance, daiOutEst)) + + // track the guaranteed floor so the cache never overstates holdings + s.uniswap.UpdateTokenBalance(addr, quoteAddr, new(big.Int).Sub(quoteBalance, quoteIn)) + s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Add(daiBalance, minDaiOut)) return tx, nil } - // sell DAI for WETH - if daiBalance.Cmp(randomAmount) < 0 { - return nil, fmt.Errorf("insufficient DAI balance for swap") - } - wethOutEst := new(big.Int).Div(new(big.Int).Mul(new(big.Int).Div(randomAmount, priceFactor), big.NewInt(95)), big.NewInt(100)) - minWethOut, err := minAmountOut(daiAddr, randomAmount) + // Selling DAI for quote tokens. + minQuoteOut := applySlippage(spotAmountOutAfterFee(slot0.SqrtPriceX96, info.Fee, amount, !quoteForDai), slippage) + tx, err := buildSwap(daiAddr, quoteAddr, amount, minQuoteOut) if err != nil { return nil, err } - params := mkParams(daiAddr, wethAddr, randomAmount, minWethOut) - tx, err := wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ - GasFeeCap: uint256.MustFromBig(feeCap), - GasTipCap: uint256.MustFromBig(tipCap), - Gas: swapGasLimit, - Value: uint256.NewInt(0), - }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { - return router.ExactInputSingle(transactOpts, params) - }) - if err != nil { - return nil, err - } - s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Sub(daiBalance, randomAmount)) - s.uniswap.UpdateTokenBalance(addr, wethAddr, new(big.Int).Add(wethBalance, wethOutEst)) + + s.uniswap.UpdateTokenBalance(addr, daiAddr, new(big.Int).Sub(daiBalance, amount)) + s.uniswap.UpdateTokenBalance(addr, quoteAddr, new(big.Int).Add(quoteBalance, minQuoteOut)) return tx, nil } diff --git a/scenarios/uniswap-swaps/uniswap.go b/scenarios/uniswap-swaps/uniswap.go index b6a782d8..5740ce84 100644 --- a/scenarios/uniswap-swaps/uniswap.go +++ b/scenarios/uniswap-swaps/uniswap.go @@ -19,18 +19,34 @@ import ( ) type UniswapOptions struct { - Version uint64 - BaseFee float64 - TipFee float64 - BaseFeeWei string - TipFeeWei string - DaiPairs uint64 - EthLiquidityPerPair *uint256.Int - DaiLiquidityFactor uint64 - FeeTier uint64 - ClientGroup string + Version uint64 + BaseFee float64 + TipFee float64 + BaseFeeWei string + TipFeeWei string + // PairCount is the number of DAI tokens to deploy; each one is paired with + // the shared quote token on both factories. + PairCount uint64 + // QuoteLiquidityPerPool is the quote token reserve seeded into every pair / + // pool; the DAI side is QuoteLiquidityPerPool * TokensPerQuote. + QuoteLiquidityPerPool *big.Int + // TokensPerQuote is the initial price: DAI tokens per quote token. + TokensPerQuote uint64 + // QuoteFunding is the quote token amount minted to each child wallet at + // startup and whenever a wallet cannot afford a buy. + QuoteFunding *big.Int + FeeTier uint64 + ClientGroup string } +// Uniswap owns the deployed contract set (v2 pairs or v3 pools) and the local +// per-wallet token balance cache used to decide swap directions without an RPC +// round trip per swap. +// +// Every pair trades a per-pair mock DAI token against one shared mock quote +// token. Both are ERC20s with a public mint, so no ETH capital beyond gas is +// needed: pools are seeded by minting and child wallets mint their own quote +// tokens. type Uniswap struct { ctx context.Context walletPool *spamoor.WalletPool @@ -39,20 +55,18 @@ type Uniswap struct { logger *logrus.Entry options UniswapOptions - // local cache of token balances + // local cache of token balances: wallet -> token -> balance tokenBalances map[common.Address]map[common.Address]*big.Int tokenBalancesMutex sync.RWMutex - // v2 contract instances - RouterA *contract.UniswapV2Router02 - RouterB *contract.UniswapV2Router02 - Weth *contract.WETH9 - Tokens map[common.Address]*contract.Dai + // contract instances bound to the static call client + RouterA *contract.UniswapV2Router02 // v2 only + RouterB *contract.UniswapV2Router02 // v2 only + Quote *contract.Dai } -// tokenAddrs returns the list of DAI token addresses across all deployed pairs -// or pools, used by the generic balance/allowance setup phases. -func (u *Uniswap) tokenAddrs() []common.Address { +// daiAddrs returns the per-pair DAI token addresses of the active deployment. +func (u *Uniswap) daiAddrs() []common.Address { if u.options.Version == 3 { addrs := make([]common.Address, 0, len(u.v3Deployment.Pools)) for _, pool := range u.v3Deployment.Pools { @@ -67,16 +81,22 @@ func (u *Uniswap) tokenAddrs() []common.Address { return addrs } -// wethAddr returns the WETH9 address of the active deployment. -func (u *Uniswap) wethAddr() common.Address { +// quoteAddr returns the shared quote token address of the active deployment. +func (u *Uniswap) quoteAddr() common.Address { if u.options.Version == 3 { - return u.v3Deployment.Weth9Addr + return u.v3Deployment.QuoteAddr } - return u.deploymentInfo.Weth9Addr + return u.deploymentInfo.QuoteAddr +} + +// allTokenAddrs returns every token a child wallet holds: all DAI tokens plus +// the quote token. Used by the generic balance/allowance setup phases. +func (u *Uniswap) allTokenAddrs() []common.Address { + return append(u.daiAddrs(), u.quoteAddr()) } // spenderAddrs returns the addresses child wallets must approve for token -// transfers: both v2 routers, or the single v3 SwapRouter. +// transfers: both v2 routers, or both v3 SwapRouters. func (u *Uniswap) spenderAddrs() []common.Address { if u.options.Version == 3 { return []common.Address{u.v3Deployment.RouterAAddr, u.v3Deployment.RouterBAddr} @@ -94,55 +114,48 @@ func NewUniswap(ctx context.Context, walletPool *spamoor.WalletPool, logger *log } } -// Initialize contract instances to reuse -func (u *Uniswap) InitializeContracts(deploymentInfo *DeploymentInfo) error { - u.deploymentInfo = deploymentInfo - +// staticCallClient returns the client used for eth_calls and for binding the +// reusable contract instances. +func (u *Uniswap) staticCallClient() (*spamoor.Client, error) { client := u.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, 0), spamoor.WithoutBuilder(), // avoid using builders for eth_calls ) if client == nil { - return fmt.Errorf("no client available") + return nil, fmt.Errorf("no client available") } - u.logger.Infof("Using client for static calls: %s", client.GetName()) + return client, nil +} + +// InitializeContracts binds the deployed v2 contract instances to the static +// call client and stores the deployment for the swap phase. +func (u *Uniswap) InitializeContracts(deploymentInfo *DeploymentInfo) error { + u.deploymentInfo = deploymentInfo - // Initialize router A - routerA, err := contract.NewUniswapV2Router02(u.deploymentInfo.UniswapRouterAAddr, client.GetEthClient()) + client, err := u.staticCallClient() if err != nil { - return fmt.Errorf("could not initialize router A: %w", err) + return err } - u.RouterA = routerA - // Initialize router B - routerB, err := contract.NewUniswapV2Router02(u.deploymentInfo.UniswapRouterBAddr, client.GetEthClient()) + u.RouterA, err = contract.NewUniswapV2Router02(deploymentInfo.UniswapRouterAAddr, client.GetEthClient()) if err != nil { - return fmt.Errorf("could not initialize router B: %w", err) + return fmt.Errorf("could not initialize router A: %w", err) } - u.RouterB = routerB - - // Initialize WETH9 - weth, err := contract.NewWETH9(u.deploymentInfo.Weth9Addr, client.GetEthClient()) + u.RouterB, err = contract.NewUniswapV2Router02(deploymentInfo.UniswapRouterBAddr, client.GetEthClient()) if err != nil { - return fmt.Errorf("could not initialize WETH9: %w", err) + return fmt.Errorf("could not initialize router B: %w", err) } - u.Weth = weth - - // Initialize token contracts - u.Tokens = make(map[common.Address]*contract.Dai) - for _, pair := range u.deploymentInfo.Pairs { - token, err := contract.NewDai(pair.DaiAddr, client.GetEthClient()) - if err != nil { - return fmt.Errorf("could not initialize token %v: %w", pair.DaiAddr, err) - } - u.Tokens[pair.DaiAddr] = token + u.Quote, err = contract.NewDai(deploymentInfo.QuoteAddr, client.GetEthClient()) + if err != nil { + return fmt.Errorf("could not initialize quote token: %w", err) } return nil } -// Initialize token balances for all wallets +// InitializeTokenBalances reads the DAI and quote token balances of all child +// wallets into the local cache. func (u *Uniswap) InitializeTokenBalances() { // Initialize the 2D map u.tokenBalances = make(map[common.Address]map[common.Address]*big.Int) @@ -150,6 +163,7 @@ func (u *Uniswap) InitializeTokenBalances() { // Get all wallets wallets := u.walletPool.GetAllWallets() + tokenAddrs := u.allTokenAddrs() // Read balances for each wallet in parallel across clients. Doing this // serially over hundreds of wallets is hundreds of blocking RPC calls; the @@ -182,9 +196,7 @@ func (u *Uniswap) InitializeTokenBalances() { } callOpts := &bind.CallOpts{Context: u.ctx} - tokenAddrs := u.tokenAddrs() - wethAddr := u.wethAddr() - balances := make(map[common.Address]*big.Int, len(tokenAddrs)+1) + balances := make(map[common.Address]*big.Int, len(tokenAddrs)) for _, tokenAddr := range tokenAddrs { token, err := contract.NewDai(tokenAddr, rclient.GetEthClient()) if err != nil { @@ -199,14 +211,6 @@ func (u *Uniswap) InitializeTokenBalances() { balances[tokenAddr] = balance } - if weth, err := contract.NewWETH9(wethAddr, rclient.GetEthClient()); err != nil { - u.logger.Errorf("could not bind WETH9: %v", err) - } else if wethBalance, err := weth.BalanceOf(callOpts, walletAddr); err != nil { - u.logger.Errorf("could not get WETH balance for %v: %v", walletAddr, err) - } else { - balances[wethAddr] = wethBalance - } - u.tokenBalancesMutex.Lock() u.tokenBalances[walletAddr] = balances u.tokenBalancesMutex.Unlock() @@ -215,7 +219,7 @@ func (u *Uniswap) InitializeTokenBalances() { wg.Wait() } -// Get DAI balance from local cache +// GetTokenBalance returns the cached balance of a token for a wallet. func (u *Uniswap) GetTokenBalance(walletAddr common.Address, tokenAddr common.Address) *big.Int { u.tokenBalancesMutex.RLock() defer u.tokenBalancesMutex.RUnlock() @@ -232,7 +236,7 @@ func (u *Uniswap) GetTokenBalance(walletAddr common.Address, tokenAddr common.Ad return balance } -// Update DAI balance in local cache +// UpdateTokenBalance overwrites the cached balance of a token for a wallet. func (u *Uniswap) UpdateTokenBalance(walletAddr common.Address, tokenAddr common.Address, newBalance *big.Int) { u.tokenBalancesMutex.Lock() defer u.tokenBalancesMutex.Unlock() @@ -251,6 +255,11 @@ func (u *Uniswap) UpdateTokenBalance(walletAddr common.Address, tokenAddr common // allowances for hundreds of wallets needs no per-tx eth_estimateGas round trip. const approvalGasLimit = 250000 +// mintGasLimit is the static gas limit for quote token mint txs. A mint to a +// wallet without a balance yet creates one fresh storage slot (like approve) +// and updates the total supply, so the same headroom applies. +const mintGasLimit = 250000 + // setupConcurrency bounds the parallel per-wallet RPC fan-out used by the setup // phases (balance reads, allowance checks). Sized to the number of healthy // clients so the load spreads across nodes, capped to avoid overwhelming them. @@ -259,9 +268,37 @@ func (u *Uniswap) setupConcurrency() int { return min(max(n, 1), 50) } -// Set unlimited allowances for all wallets to both routers -func (u *Uniswap) SetUnlimitedAllowances() error { - u.logger.Infof("Setting unlimited allowances for all wallets...") +// buildQuoteMintTx builds a tx in which the wallet mints QuoteFunding quote +// tokens to itself, and bumps the cached balance accordingly. The quote token +// is a mock with a public mint, so this is how child wallets are capitalized +// instead of receiving ETH. +func (u *Uniswap) buildQuoteMintTx(ctx context.Context, wallet *spamoor.Wallet, feeCap, tipCap *big.Int) (*txtypes.Transaction, error) { + quoteAddr := u.quoteAddr() + walletAddr := wallet.GetAddress() + + tx, err := wallet.BuildBoundTx(ctx, &txbuilder.TxMetadata{ + GasFeeCap: uint256.MustFromBig(feeCap), + GasTipCap: uint256.MustFromBig(tipCap), + Gas: mintGasLimit, + Value: uint256.NewInt(0), + }, func(transactOpts *bind.TransactOpts) (*types.Transaction, error) { + return u.Quote.Mint(transactOpts, walletAddr, u.options.QuoteFunding) + }) + if err != nil { + return nil, fmt.Errorf("could not build quote mint tx: %w", err) + } + + balance := u.GetTokenBalance(walletAddr, quoteAddr) + u.UpdateTokenBalance(walletAddr, quoteAddr, new(big.Int).Add(balance, u.options.QuoteFunding)) + return tx, nil +} + +// PrepareWallets gets every child wallet ready for swapping: it sets unlimited +// allowances for all tokens to the router(s) and mints the initial quote token +// funding to wallets holding less than that amount. Must run after +// InitializeTokenBalances, whose cache decides which wallets need funding. +func (u *Uniswap) PrepareWallets() error { + u.logger.Infof("Preparing wallets (allowances + quote token funding)...") // Get all wallets wallets := u.walletPool.GetAllWallets() @@ -285,22 +322,30 @@ func (u *Uniswap) SetUnlimitedAllowances() error { } routers := u.spenderAddrs() - tokenAddrs := u.tokenAddrs() - wethAddr := u.wethAddr() + tokenAddrs := u.allTokenAddrs() + quoteAddr := u.quoteAddr() - // Track all approval transactions + // Track all setup transactions var ( - approvalTxs []*txtypes.Transaction - approvalWallets []*spamoor.Wallet - mu sync.Mutex - wg sync.WaitGroup + setupTxs []*txtypes.Transaction + setupWallets []*spamoor.Wallet + mintCount int + mu sync.Mutex + wg sync.WaitGroup ) + addSetupTx := func(wallet *spamoor.Wallet, tx *txtypes.Transaction) { + mu.Lock() + setupTxs = append(setupTxs, tx) + setupWallets = append(setupWallets, wallet) + mu.Unlock() + } + // Check allowances and build approval txs in parallel across clients. For N - // wallets this is up to 4*N allowance reads (DAI+WETH × router A+B); doing - // them serially on one client blocks the scenario for minutes at large wallet - // counts. The context-aware CallOpts also let a UI stop actually cancel the - // in-flight reads. + // wallets this is up to 2*(pairs+1)*N allowance reads (every token × router + // A+B); doing them serially on one client blocks the scenario for minutes at + // large wallet counts. The context-aware CallOpts also let a UI stop actually + // cancel the in-flight reads. sem := make(chan struct{}, u.setupConcurrency()) buildApproval := func(wallet *spamoor.Wallet, approve func(*bind.TransactOpts) (*types.Transaction, error)) { @@ -316,10 +361,7 @@ func (u *Uniswap) SetUnlimitedAllowances() error { u.logger.Errorf("could not build approval tx for %v: %v", wallet.GetAddress(), err) return } - mu.Lock() - approvalTxs = append(approvalTxs, approveTx) - approvalWallets = append(approvalWallets, wallet) - mu.Unlock() + addSetupTx(wallet, approveTx) } for idx, wallet := range wallets { @@ -346,7 +388,6 @@ func (u *Uniswap) SetUnlimitedAllowances() error { } callOpts := &bind.CallOpts{Context: u.ctx} - // DAI tokens for _, tokenAddr := range tokenAddrs { token, err := contract.NewDai(tokenAddr, rclient.GetEthClient()) if err != nil { @@ -368,24 +409,17 @@ func (u *Uniswap) SetUnlimitedAllowances() error { } } - // WETH - weth, err := contract.NewWETH9(wethAddr, rclient.GetEthClient()) - if err != nil { - u.logger.Errorf("could not bind WETH9: %v", err) - return - } - for _, router := range routers { - allowance, err := weth.Allowance(callOpts, wallet.GetAddress(), router) + // Initial quote token funding for wallets below the funding amount. + if u.GetTokenBalance(wallet.GetAddress(), quoteAddr).Cmp(u.options.QuoteFunding) < 0 { + mintTx, err := u.buildQuoteMintTx(u.ctx, wallet, feeCap, tipCap) if err != nil { - u.logger.Errorf("could not check WETH allowance for %v: %v", wallet.GetAddress(), err) - continue + u.logger.Errorf("could not build quote mint tx for %v: %v", wallet.GetAddress(), err) + return } - if allowance.Cmp(maxAllowance) >= 0 { - continue - } - buildApproval(wallet, func(opts *bind.TransactOpts) (*types.Transaction, error) { - return weth.Approve(opts, router, maxAllowance) - }) + mu.Lock() + mintCount++ + mu.Unlock() + addSetupTx(wallet, mintTx) } }(idx, wallet) } @@ -395,13 +429,13 @@ func (u *Uniswap) SetUnlimitedAllowances() error { return u.ctx.Err() } - // Send all approval transactions in parallel - if len(approvalTxs) > 0 { - u.logger.Infof("Sending %d approval transactions...", len(approvalTxs)) + // Send all setup transactions in parallel + if len(setupTxs) > 0 { + u.logger.Infof("Sending %d wallet setup transactions (%d approvals, %d quote mints)...", len(setupTxs), len(setupTxs)-mintCount, mintCount) // Reuse the wait group (back to zero after the build phase) to track sends. // Send each transaction to a different client - for i, tx := range approvalTxs { + for i, tx := range setupTxs { // Get a different client for each transaction txClient := u.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, i), @@ -420,19 +454,19 @@ func (u *Uniswap) SetUnlimitedAllowances() error { Rebroadcast: true, OnComplete: func(tx *txtypes.Transaction, receipt *txtypes.Receipt, err error) { if err != nil { - u.logger.Errorf("approval tx failed: %v", err) + u.logger.Errorf("wallet setup tx failed: %v", err) } wg.Done() }, }) - }(tx, txClient, approvalWallets[i]) + }(tx, txClient, setupWallets[i]) } // Wait for all transactions to be sent wg.Wait() - u.logger.Infof("All approval transactions sent") + u.logger.Infof("All wallet setup transactions sent") } else { - u.logger.Infof("No approval transactions needed (allowances already set)") + u.logger.Infof("No wallet setup transactions needed (allowances and funding already in place)") } return nil diff --git a/scenarios/uniswap-swaps/uniswap_math.go b/scenarios/uniswap-swaps/uniswap_math.go index 82e710b6..832e3c5c 100644 --- a/scenarios/uniswap-swaps/uniswap_math.go +++ b/scenarios/uniswap-swaps/uniswap_math.go @@ -6,12 +6,15 @@ import ( // Uniswap v3 fixed-point math helpers. These mirror the relevant parts of the // Uniswap v3 SDK / TickMath / LiquidityAmounts libraries, just enough to seed a -// full-range position at a chosen starting price. +// full-range position at a chosen starting price and to quote swaps at spot. const ( // v3 tick bounds as defined by TickMath. minTick int64 = -887272 maxTick int64 = 887272 + + // v3 fee denominator: fees are expressed in hundredths of a bip. + feeDenominator = 1_000_000 ) var ( @@ -80,18 +83,46 @@ func spotAmountOut(sqrtPriceX96, amountIn *big.Int, zeroForOne bool) *big.Int { return new(big.Int).Div(new(big.Int).Mul(amountIn, q192), priceX192) } -// fullRangeLiquidityForWeth computes the full-range liquidity bounded by the -// available WETH budget. DAI is minted on demand by the liquidity provider, so -// only the WETH side constrains how much liquidity can be seeded. sqrtPriceX96 -// is the pool's current price and wethIsToken0 indicates the token ordering. -func fullRangeLiquidityForWeth(sqrtPriceX96 *big.Int, wethIsToken0 bool, wethBudget *big.Int) *big.Int { +// spotAmountOutAfterFee returns the spot output for an exact input swap after +// the pool fee (which v3 takes on the input), ignoring price impact. +func spotAmountOutAfterFee(sqrtPriceX96, fee, amountIn *big.Int, zeroForOne bool) *big.Int { + feeDenom := big.NewInt(feeDenominator) + amountInAfterFee := new(big.Int).Div(new(big.Int).Mul(amountIn, new(big.Int).Sub(feeDenom, fee)), feeDenom) + return spotAmountOut(sqrtPriceX96, amountInAfterFee, zeroForOne) +} + +// spotAmountIn returns the input needed to receive amountOut at the pool's +// current spot price, grossed up for the pool fee (rounded up), ignoring price +// impact. zeroForOne indicates the swap direction (token0 in, token1 out). +func spotAmountIn(sqrtPriceX96, fee, amountOut *big.Int, zeroForOne bool) *big.Int { + // the spot price is symmetric: input = amountOut priced in the opposite direction + amountInAfterFee := spotAmountOut(sqrtPriceX96, amountOut, !zeroForOne) + + // amountIn = ceil(amountInAfterFee * denom / (denom - fee)) + feeDenom := big.NewInt(feeDenominator) + num := new(big.Int).Mul(amountInAfterFee, feeDenom) + den := new(big.Int).Sub(feeDenom, fee) + amountIn := new(big.Int).Add(num, new(big.Int).Sub(den, big.NewInt(1))) + amountIn.Div(amountIn, den) + if amountIn.Sign() == 0 { + amountIn = big.NewInt(1) + } + return amountIn +} + +// fullRangeLiquidityForToken computes the full-range liquidity that puts +// tokenAmount of one token into the position at the given price. The other +// token is minted on demand by the liquidity provider, so only this side +// constrains how much liquidity is seeded. sqrtPriceX96 is the pool's current +// price and tokenIsToken0 indicates whether the sized token is token0. +func fullRangeLiquidityForToken(sqrtPriceX96 *big.Int, tokenIsToken0 bool, tokenAmount *big.Int) *big.Int { var liquidity *big.Int - if wethIsToken0 { - // token0 is WETH: amount0 is supplied over [current, max]. - liquidity = getLiquidityForAmount0(sqrtPriceX96, maxSqrtRatio, wethBudget) + if tokenIsToken0 { + // token0 is supplied over [current, max]. + liquidity = getLiquidityForAmount0(sqrtPriceX96, maxSqrtRatio, tokenAmount) } else { - // token1 is WETH: amount1 is supplied over [min, current]. - liquidity = getLiquidityForAmount1(minSqrtRatio, sqrtPriceX96, wethBudget) + // token1 is supplied over [min, current]. + liquidity = getLiquidityForAmount1(minSqrtRatio, sqrtPriceX96, tokenAmount) } if liquidity.Cmp(maxUint128) > 0 { liquidity = new(big.Int).Set(maxUint128) diff --git a/scenarios/uniswap-swaps/uniswap_math_test.go b/scenarios/uniswap-swaps/uniswap_math_test.go index 9f72f836..22440861 100644 --- a/scenarios/uniswap-swaps/uniswap_math_test.go +++ b/scenarios/uniswap-swaps/uniswap_math_test.go @@ -7,10 +7,10 @@ import ( func TestEncodeSqrtRatioX96(t *testing.T) { // price = amount1/amount0 = 10000 -> sqrt = 100 -> sqrtPriceX96 = 100 * 2^96 - weth := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) - dai := new(big.Int).Mul(weth, big.NewInt(10000)) + quote := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) + dai := new(big.Int).Mul(quote, big.NewInt(10000)) - got := encodeSqrtRatioX96(dai, weth) + got := encodeSqrtRatioX96(dai, quote) want := new(big.Int).Mul(big.NewInt(100), q96) if got.Cmp(want) != 0 { t.Fatalf("encodeSqrtRatioX96 = %s, want %s", got, want) @@ -26,9 +26,9 @@ func TestFullRangeTicks(t *testing.T) { func TestSpotAmountOut(t *testing.T) { // price = token1/token0 = 10000 - weth := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) - dai := new(big.Int).Mul(weth, big.NewInt(10000)) - sqrtP := encodeSqrtRatioX96(dai, weth) + quote := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) + dai := new(big.Int).Mul(quote, big.NewInt(10000)) + sqrtP := encodeSqrtRatioX96(dai, quote) // token0 -> token1: 1 token0 yields 10000 token1 in := big.NewInt(1e18) @@ -45,21 +45,53 @@ func TestSpotAmountOut(t *testing.T) { } } -func TestFullRangeLiquidityForWeth(t *testing.T) { - weth := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) - dai := new(big.Int).Mul(weth, big.NewInt(10000)) +func TestSpotAmountInRoundTrip(t *testing.T) { + // price = token1/token0 = 10000, fee = 0.3% + quote := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) + dai := new(big.Int).Mul(quote, big.NewInt(10000)) + sqrtP := encodeSqrtRatioX96(dai, quote) + fee := big.NewInt(3000) - // weth as token0 - sqrtP0 := encodeSqrtRatioX96(dai, weth) - l0 := fullRangeLiquidityForWeth(sqrtP0, true, weth) + // buying 10000 token1 costs 1 token0 plus the 0.3% fee on the input + wantOut := new(big.Int).Mul(big.NewInt(10000), big.NewInt(1e18)) + in := spotAmountIn(sqrtP, fee, wantOut, true) + if in.Cmp(big.NewInt(1e18)) <= 0 { + t.Fatalf("spotAmountIn = %s, want > 1e18 (fee must be included)", in) + } + + // swapping that input back through the fee model yields at least the target + out := spotAmountOutAfterFee(sqrtP, fee, in, true) + if out.Cmp(wantOut) < 0 { + t.Fatalf("spotAmountOutAfterFee(spotAmountIn(x)) = %s, want >= %s", out, wantOut) + } + + // the rounding slack is at most one output unit per input unit of price + slack := new(big.Int).Sub(out, wantOut) + if slack.Cmp(big.NewInt(10000)) > 0 { + t.Fatalf("spotAmountIn overshoots by %s, want <= 10000", slack) + } + + // tiny outputs never round down to a zero input + if spotAmountIn(sqrtP, fee, big.NewInt(1), true).Sign() <= 0 { + t.Fatalf("spotAmountIn must be at least 1") + } +} + +func TestFullRangeLiquidityForToken(t *testing.T) { + quote := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) + dai := new(big.Int).Mul(quote, big.NewInt(10000)) + + // quote as token0 + sqrtP0 := encodeSqrtRatioX96(dai, quote) + l0 := fullRangeLiquidityForToken(sqrtP0, true, quote) if l0.Sign() <= 0 || l0.Cmp(maxUint128) > 0 { - t.Fatalf("liquidity (weth token0) out of range: %s", l0) + t.Fatalf("liquidity (quote token0) out of range: %s", l0) } - // weth as token1 - sqrtP1 := encodeSqrtRatioX96(weth, dai) - l1 := fullRangeLiquidityForWeth(sqrtP1, false, weth) + // quote as token1 + sqrtP1 := encodeSqrtRatioX96(quote, dai) + l1 := fullRangeLiquidityForToken(sqrtP1, false, quote) if l1.Sign() <= 0 || l1.Cmp(maxUint128) > 0 { - t.Fatalf("liquidity (weth token1) out of range: %s", l1) + t.Fatalf("liquidity (quote token1) out of range: %s", l1) } } diff --git a/scenarios/uniswap-swaps/uniswap_swaps.go b/scenarios/uniswap-swaps/uniswap_swaps.go index 4fdf2014..084538f7 100644 --- a/scenarios/uniswap-swaps/uniswap_swaps.go +++ b/scenarios/uniswap-swaps/uniswap_swaps.go @@ -3,7 +3,9 @@ package uniswapswaps import ( "context" "fmt" + "math/big" mathrand "math/rand" + "sync" "time" "github.com/holiman/uint256" @@ -36,6 +38,7 @@ type ScenarioOptions struct { SlippageMin uint64 `yaml:"slippage_min"` SlippageMax uint64 `yaml:"slippage_max"` SellThreshold string `yaml:"sell_threshold"` + QuoteFunding string `yaml:"quote_funding"` Timeout string `yaml:"timeout"` ClientGroup string `yaml:"client_group"` DeployClientGroup string `yaml:"deploy_client_group"` @@ -49,16 +52,35 @@ type Scenario struct { uniswap *Uniswap deploymentInfo *DeploymentInfo + + // wei amounts parsed once from the string options + minSwapAmount *big.Int + maxSwapAmount *big.Int + sellThreshold *big.Int + quoteFunding *big.Int + + // randomness for swap sizing; ProcessNextTxFn runs concurrently so the + // source is guarded + randMu sync.Mutex + rand *mathrand.Rand } // swapGasLimit is the static gas limit used for all swap (spam) transactions. // Swaps deliberately avoid per-tx gas estimation to skip the extra RPC round // trip on the hot path. Under the Amsterdam fee schedule a swap that creates -// fresh state (e.g. the recipient's first token balance slot, WETH wrap/unwrap) -// costs ~410k gas; this limit keeps comfortable headroom for the heaviest swap -// variant. Bump it if a future fee schedule raises state-creation cost again. +// fresh state (e.g. the recipient's first token balance slot) costs ~410k gas; +// this limit keeps comfortable headroom for the heaviest swap variant. Bump it +// if a future fee schedule raises state-creation cost again. const swapGasLimit = 600000 +// Every pool is seeded with quoteLiquidityPerPool quote tokens and +// quoteLiquidityPerPool * tokensPerQuote pair tokens, which fixes the initial +// price at tokensPerQuote pair tokens per quote token. Both tokens are minted +// on demand, so the depth costs nothing but gas. +const tokensPerQuote = 10000 + +var quoteLiquidityPerPool = new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18)) + var ScenarioName = "uniswap-swaps" var ScenarioDefaultOptions = ScenarioOptions{ TotalCount: 0, @@ -78,6 +100,7 @@ var ScenarioDefaultOptions = ScenarioOptions{ SlippageMin: 0, SlippageMax: 0, SellThreshold: "50000000000000000000000", // 50000 DAI + QuoteFunding: "5000000000000000000", // 5 quote tokens (= 50000 DAI at the seeded price) Timeout: "", ClientGroup: "", DeployClientGroup: "", @@ -94,6 +117,7 @@ func newScenario(logger logrus.FieldLogger) scenario.Scenario { return &Scenario{ options: ScenarioDefaultOptions, logger: logger.WithField("scenario", ScenarioName), + rand: mathrand.New(mathrand.NewSource(time.Now().UnixNano())), } } @@ -110,13 +134,14 @@ func (s *Scenario) Flags(flags *pflag.FlagSet) error { flags.Uint64Var(&s.options.Version, "uniswap-version", ScenarioDefaultOptions.Version, "Uniswap version to use (2 or 3)") flags.Uint64Var(&s.options.FeeTier, "fee-tier", ScenarioDefaultOptions.FeeTier, "Uniswap v3 fee tier in hundredths of a bip (500, 3000, 10000)") flags.Uint64Var(&s.options.PairCount, "pair-count", ScenarioDefaultOptions.PairCount, "Number of uniswap pairs to deploy") - flags.StringVar(&s.options.MinSwapAmount, "min-swap", ScenarioDefaultOptions.MinSwapAmount, "Minimum swap amount in wei") - flags.StringVar(&s.options.MaxSwapAmount, "max-swap", ScenarioDefaultOptions.MaxSwapAmount, "Maximum swap amount in wei") + flags.StringVar(&s.options.MinSwapAmount, "min-swap", ScenarioDefaultOptions.MinSwapAmount, "Minimum swap amount in DAI wei") + flags.StringVar(&s.options.MaxSwapAmount, "max-swap", ScenarioDefaultOptions.MaxSwapAmount, "Maximum swap amount in DAI wei") flags.Uint64Var(&s.options.BuyRatio, "buy-ratio", ScenarioDefaultOptions.BuyRatio, "Ratio of buy vs sell swaps (0-100)") flags.Uint64Var(&s.options.Slippage, "slippage", ScenarioDefaultOptions.Slippage, "Slippage tolerance in basis points") flags.Uint64Var(&s.options.SlippageMin, "slippage-min", ScenarioDefaultOptions.SlippageMin, "Min per-trade slippage in bps (0 disables; use fixed --slippage)") flags.Uint64Var(&s.options.SlippageMax, "slippage-max", ScenarioDefaultOptions.SlippageMax, "Max per-trade slippage in bps (when > slippage-min, slippage is randomized per trade)") flags.StringVar(&s.options.SellThreshold, "sell-threshold", ScenarioDefaultOptions.SellThreshold, "DAI balance threshold to force sell (in wei)") + flags.StringVar(&s.options.QuoteFunding, "quote-funding", ScenarioDefaultOptions.QuoteFunding, "Quote token amount (in wei) minted to each child wallet at startup and whenever a wallet cannot afford a buy") flags.StringVar(&s.options.Timeout, "timeout", ScenarioDefaultOptions.Timeout, "Timeout for the scenario (e.g. '1h', '30m', '5s') - empty means no timeout") flags.StringVar(&s.options.ClientGroup, "client-group", ScenarioDefaultOptions.ClientGroup, "Client group to use for sending transactions") flags.StringVar(&s.options.DeployClientGroup, "deploy-client-group", ScenarioDefaultOptions.DeployClientGroup, "Client group to use for deployments") @@ -139,6 +164,26 @@ func (s *Scenario) Init(options *scenario.Options) error { return fmt.Errorf("invalid uniswap version %d, must be 2 or 3", s.options.Version) } + var err error + if s.minSwapAmount, err = parseWei("min swap amount", s.options.MinSwapAmount); err != nil { + return err + } + if s.maxSwapAmount, err = parseWei("max swap amount", s.options.MaxSwapAmount); err != nil { + return err + } + if s.sellThreshold, err = parseWei("sell threshold", s.options.SellThreshold); err != nil { + return err + } + if s.quoteFunding, err = parseWei("quote funding", s.options.QuoteFunding); err != nil { + return err + } + if s.minSwapAmount.Cmp(s.maxSwapAmount) > 0 { + return fmt.Errorf("min swap amount %s exceeds max swap amount %s", s.options.MinSwapAmount, s.options.MaxSwapAmount) + } + if s.quoteFunding.Sign() <= 0 { + return fmt.Errorf("quote funding must be positive") + } + if s.options.MaxWallets > 0 { s.walletPool.SetWalletCount(s.options.MaxWallets) } else if s.options.TotalCount > 0 { @@ -177,6 +222,15 @@ func (s *Scenario) Init(options *scenario.Options) error { return nil } +// parseWei parses a decimal wei amount from a string option. +func parseWei(name, value string) (*big.Int, error) { + amount, ok := new(big.Int).SetString(value, 10) + if !ok || amount.Sign() < 0 { + return nil, fmt.Errorf("invalid %s: %s", name, value) + } + return amount, nil +} + func (s *Scenario) Run(ctx context.Context) error { s.logger.Infof("starting scenario: %s (V%d)", ScenarioName, s.options.Version) defer s.logger.Infof("scenario %s finished.", ScenarioName) @@ -188,16 +242,17 @@ func (s *Scenario) Run(ctx context.Context) error { // deploy uniswap contracts s.uniswap = NewUniswap(ctx, s.walletPool, s.logger, UniswapOptions{ - Version: s.options.Version, - BaseFee: s.options.BaseFee, - TipFee: s.options.TipFee, - BaseFeeWei: s.options.BaseFeeWei, - TipFeeWei: s.options.TipFeeWei, - DaiPairs: s.options.PairCount, - EthLiquidityPerPair: uint256.NewInt(0).Mul(uint256.NewInt(2000), uint256.NewInt(1000000000000000000)), - DaiLiquidityFactor: 10000, - FeeTier: s.options.FeeTier, - ClientGroup: deployClientGroup, + Version: s.options.Version, + BaseFee: s.options.BaseFee, + TipFee: s.options.TipFee, + BaseFeeWei: s.options.BaseFeeWei, + TipFeeWei: s.options.TipFeeWei, + PairCount: s.options.PairCount, + QuoteLiquidityPerPool: quoteLiquidityPerPool, + TokensPerQuote: tokensPerQuote, + QuoteFunding: s.quoteFunding, + FeeTier: s.options.FeeTier, + ClientGroup: deployClientGroup, }) if s.options.Version == 3 { @@ -221,7 +276,7 @@ func (s *Scenario) Run(ctx context.Context) error { return err } if deploymentInfo == nil { - return fmt.Errorf("could not deploy uniswap pairs: %w", err) + return fmt.Errorf("could not deploy uniswap pairs") } s.deploymentInfo = deploymentInfo @@ -233,9 +288,9 @@ func (s *Scenario) Run(ctx context.Context) error { s.uniswap.InitializeTokenBalances() - // Set unlimited allowances for all wallets to the router(s) - if err := s.uniswap.SetUnlimitedAllowances(); err != nil { - s.logger.Errorf("could not set unlimited allowances: %v", err) + // Approve the router(s) and fund every child wallet with quote tokens + if err := s.uniswap.PrepareWallets(); err != nil { + s.logger.Errorf("could not prepare wallets: %v", err) return err } @@ -326,6 +381,38 @@ func (s *Scenario) perTradeSlippage() uint64 { return slippage } +// randomSwapAmount draws a uniform random DAI amount from +// [min_swap_amount, max_swap_amount]. +func (s *Scenario) randomSwapAmount() *big.Int { + span := new(big.Int).Sub(s.maxSwapAmount, s.minSwapAmount) + span.Add(span, big.NewInt(1)) + + s.randMu.Lock() + defer s.randMu.Unlock() + return new(big.Int).Add(s.minSwapAmount, new(big.Int).Rand(s.rand, span)) +} + +// decideSwapSide picks buy (true) or sell (false) for a trade of the given DAI +// amount from the configured buy ratio and the wallet's tracked DAI balance: a +// wallet holding more than the sell threshold is forced to sell so pools don't +// get drained one-sided, and a wallet that cannot cover the sell buys instead. +func (s *Scenario) decideSwapSide(daiBalance, amount *big.Int) bool { + isBuy := mathrand.Intn(100) < int(s.options.BuyRatio) + if daiBalance.Cmp(s.sellThreshold) > 0 { + isBuy = false + } + if !isBuy && daiBalance.Cmp(amount) < 0 { + isBuy = true + } + return isBuy +} + +// applySlippage returns amount reduced by the given tolerance in basis points. +func applySlippage(amount *big.Int, slippageBps uint64) *big.Int { + out := new(big.Int).Mul(amount, big.NewInt(10000-int64(slippageBps))) + return out.Div(out, big.NewInt(10000)) +} + func (s *Scenario) sendTx(ctx context.Context, txIdx uint64) (scenario.ReceiptChan, *txtypes.Transaction, *spamoor.Client, *spamoor.Wallet, error) { client := s.walletPool.GetClient( spamoor.WithClientSelectionMode(spamoor.SelectClientByIndex, int(txIdx)),