From 6599e3f30640935a0decfd2e35247530f8973a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20Faruk=20IRMAK?= Date: Thu, 10 Sep 2026 11:31:18 +0300 Subject: [PATCH] feat(uniswap-swaps): trade ERC20/ERC20 pairs instead of ERC20/ETH Every pair now trades a per-pair mock DAI token against one shared mock quote token. Both are instances of the existing Dai mock, whose mint is public, so the scenario no longer needs ETH capital beyond gas: - The v2 and v3 liquidity-provider helpers mint both tokens on demand and are called by the owner wallet; the root wallet lock and the 2000 ETH per pool are gone. Re-runs skip pairs/pools that already hold reserves. - Child wallets mint their own quote tokens during setup and re-mint when a buy would exceed their balance (new --quote-funding flag). - Both swap builders lose the ETH/WETH branching and do a single token-for-token swap. The v3 path derives the required input from the pool's spot price and fee and tracks the guaranteed minimum output. - WETH9 stays deployed only as the routers' constructor dependency. Also fixes v2 with --pair-count >= 2 collapsing every DAI onto the same address (salt written into the wrong seed buffer), and drops the dead rely phase (only the CREATE2 proxy ever held ward rights). Co-Authored-By: Claude Fable 5.1 --- scenarios/uniswap-swaps/README.md | 24 +- .../contract/PairLiquidityProvider.go | 109 ++----- .../contract/PairLiquidityProvider.sol | 286 ++---------------- .../contract/V3LiquidityProvider.go | 39 +-- .../contract/V3LiquidityProvider.sol | 63 +--- scenarios/uniswap-swaps/deployment.go | 246 +++++++-------- scenarios/uniswap-swaps/deployment_v3.go | 213 +++++++------ scenarios/uniswap-swaps/swap_v2.go | 281 ++++------------- scenarios/uniswap-swaps/swap_v3.go | 172 +++-------- scenarios/uniswap-swaps/uniswap.go | 252 ++++++++------- scenarios/uniswap-swaps/uniswap_math.go | 53 +++- scenarios/uniswap-swaps/uniswap_math_test.go | 66 ++-- scenarios/uniswap-swaps/uniswap_swaps.go | 125 ++++++-- 13 files changed, 751 insertions(+), 1178 deletions(-) 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)),