Skip to content

Commit ebfb39d

Browse files
authored
feat: gasless approval for solvers (#201)
* feat(onlyswaps-solver): permit2 transferFrom support * feat(onlyswaps-solver): permit2 witnessTransferFrom support * feat(onlyswaps-solver): method to create permit2 approval for Permit2Relayer contract * chore: upgrade alloy * feat(onlyswaps-solver): use gasless approval when relaying tokens * fix(onlyswaps-solver): make permit2 relayer address dynamic * chore(onlyswaps-solver): rename `PERMIT2_ADDRESS` to `DEFAULT_PERMIT2_ADDRESS` * refactor(onlyswaps-solver/permit2): return err instead of repeated unwraps * feat(onlyswaps-solver/permit2): include `solverRefundAddress` as additional data * feat(generated): generate Permit2Relayer bindings * chore(bindings): add deploy_permit2_relayer.rs * feat(solver): add subcommand to issue token approvals * refactor(solver): rename routers to configs * feat(solver): decode permit2 errors * feat(solver): properly log successful approval txs * fix(solver): allow permit2 address overrides in executor
1 parent 37eb50e commit ebfb39d

19 files changed

Lines changed: 4731 additions & 145 deletions

Cargo.lock

Lines changed: 777 additions & 76 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ utils = { path = "./crates/utils", features = ["bn254", "bls12-381"] }
2626
onlyswaps-client = { path = "./crates/onlyswaps-client" }
2727

2828
# blockchain
29-
alloy = { version = "1.0.32", features = ["sol-types"] }
29+
alloy = { version = "1.0.41", features = ["sol-types"] }
3030

3131
# crypto
3232
ark-bn254 = "0.5.0"

bin/onlyswaps-solver/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version.workspace = true
44
edition.workspace = true
55

66
[dependencies]
7-
alloy = { workspace = true, features = ["provider-ws", "provider-anvil-node"] }
7+
alloy = { workspace = true, features = ["provider-ws", "provider-anvil-node", "eip712", "rand"] }
88
agent-utils.workspace = true
99
anyhow.workspace = true
1010
async-trait.workspace = true

bin/onlyswaps-solver/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,13 @@ chain_id = 43113
3232
rpc_url = "wss://avalanche-fuji-c-chain-rpc.publicnode.com"
3333
tokens = ["0x1b0F6cF6f3185872a581BD2B5a738EB52CCd4d76"]
3434
router_address = "0x83b2dFc83E41a2398e28e31C352E1053805e4C16"
35+
permit2_relayer_address = "0x862acc167842c72B6f5B6b4091573dDE91A5AcfB"
3536

3637
[[networks]]
3738
chain_id = 84532
3839
rpc_url = "wss://base-sepolia-rpc.publicnode.com"
3940
tokens = ["0x1b0F6cF6f3185872a581BD2B5a738EB52CCd4d76"]
4041
router_address = "0x83b2dFc83E41a2398e28e31C352E1053805e4C16"
42+
permit2_relayer_address = "0x862acc167842c72B6f5B6b4091573dDE91A5AcfB"
4143

4244
```

bin/onlyswaps-solver/config-default.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ chain_id = 43113
99
rpc_url = "wss://avalanche-fuji-c-chain-rpc.publicnode.com"
1010
tokens = ["0x1b0F6cF6f3185872a581BD2B5a738EB52CCd4d76"]
1111
router_address = "0x83b2dFc83E41a2398e28e31C352E1053805e4C16"
12+
permit2_relayer_address = "0x862acc167842c72B6f5B6b4091573dDE91A5AcfB"
1213

1314
[[networks]]
1415
chain_id = 84532
1516
rpc_url = "wss://base-sepolia-rpc.publicnode.com"
1617
tokens = ["0x1b0F6cF6f3185872a581BD2B5a738EB52CCd4d76"]
1718
router_address = "0x83b2dFc83E41a2398e28e31C352E1053805e4C16"
19+
permit2_relayer_address = "0x862acc167842c72B6f5B6b4091573dDE91A5AcfB"

bin/onlyswaps-solver/src/app.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::profitability::{
99
};
1010
use crate::solver::Solver;
1111
use alloy::providers::DynProvider;
12+
use alloy::signers::local::PrivateKeySigner;
1213
use config::timeout::TimeoutConfig;
1314
use futures::StreamExt;
1415
use futures::future::try_join_all;
@@ -20,6 +21,7 @@ use std::ops::Mul;
2021
pub struct App {}
2122
impl App {
2223
pub async fn start(
24+
signer: PrivateKeySigner,
2325
networks: HashMap<u64, Network<DynProvider>>,
2426
timeout: &TimeoutConfig,
2527
profitability: &ProfitabilityConfig,
@@ -52,7 +54,7 @@ impl App {
5254
let mut stream = Box::pin(select_all(streams));
5355
let fee_estimator = DefaultFeeAdapter::new();
5456
let mut solver = Solver::new(&networks, &fee_estimator).await?;
55-
let executor = TradeExecutor::new(&networks, pe);
57+
let executor = TradeExecutor::new(signer, &networks, pe).await?;
5658

5759
// we pull new chain state every block, so inflight requests may not have been
5860
// completed yet, so we don't want to attempt to execute them again and waste gas.

bin/onlyswaps-solver/src/config.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use alloy::primitives::Address;
2-
use clap::Parser;
2+
use clap::{Parser, Subcommand};
33
use config::agent::AgentConfig;
44
use config::timeout::TimeoutConfig;
55
use serde::Deserialize;
@@ -16,6 +16,21 @@ pub(crate) struct CliArgs {
1616

1717
#[arg(short = 's', long = "private-key", env = "SOLVER_PRIVATE_KEY")]
1818
pub private_key: String,
19+
20+
/// By default, run the solver
21+
#[command(subcommand)]
22+
pub command: Option<Command>,
23+
}
24+
25+
#[derive(Subcommand, Deserialize, Debug, Clone)]
26+
pub enum Command {
27+
/// Run the solver
28+
#[command(about = "Run the solver")]
29+
Run,
30+
31+
/// Setup the solver by submitting token approvals
32+
#[command(about = "Setup the solver")]
33+
Setup,
1934
}
2035

2136
#[derive(Deserialize, Debug, Clone)]
@@ -34,6 +49,7 @@ pub(crate) struct NetworkConfig {
3449
pub rpc_url: String,
3550
pub tokens: Vec<Address>,
3651
pub router_address: Address,
52+
pub permit2_relayer_address: Address,
3753
#[serde(default = "default_tx_gas_buffer")]
3854
pub tx_gas_buffer: u16,
3955
#[serde(default = "default_tx_gas_price_buffer")]
@@ -87,3 +103,9 @@ impl Default for ProfitabilityConfig {
87103
}
88104
}
89105
}
106+
107+
impl CliArgs {
108+
pub fn command(&self) -> Command {
109+
self.command.clone().unwrap_or(Command::Run)
110+
}
111+
}

bin/onlyswaps-solver/src/executor.rs

Lines changed: 88 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,83 @@
1+
use crate::gasless::{
2+
Permit2RelayTokensDetails, fetch_permit2_addresses, permit2, permit2_relay_tokens_details,
3+
};
14
use crate::model::{RequestId, Trade};
25
use crate::network::Network;
36
use crate::profitability::{ErasedProfitabilityEstimator, ProfitabilityEstimator};
47
use crate::util::normalise_chain_id;
58
use alloy::primitives::{Address, TxHash};
69
use alloy::providers::Provider;
10+
use alloy::signers::Signer;
711
use anyhow::{Context, anyhow};
812
use config::timeout::TimeoutConfig;
9-
use generated::onlyswaps::erc20_faucet_token::ERC20FaucetToken::ERC20FaucetTokenInstance;
1013
use generated::onlyswaps::errors_lib::ErrorsLib::ErrorsLibErrors;
11-
use generated::onlyswaps::i_router::IRouter::IRouterInstance;
14+
use generated::onlyswaps::i_router::IRouter::{IRouterInstance, RelayTokensPermit2Params};
1215
use generated::onlyswaps::ierc20_errors::IERC20Errors::IERC20ErrorsErrors as IERC20Errors;
1316
use moka::future::Cache;
1417
use std::collections::HashMap;
1518
use tokio::time::timeout;
1619

17-
pub(crate) struct TradeExecutor<'a, P> {
20+
pub(crate) struct TradeExecutor<'a, P, S> {
21+
signer: S,
1822
own_address: Address,
19-
routers: HashMap<u64, &'a IRouterInstance<P>>,
20-
tokens: HashMap<u64, &'a Vec<ERC20FaucetTokenInstance<P>>>,
23+
configs: HashMap<u64, ChainConfig<'a, P>>,
2124
profitability_estimator: ErasedProfitabilityEstimator,
2225
}
2326

24-
impl<'a, P> TradeExecutor<'a, P>
27+
pub(crate) struct ChainConfig<'a, P> {
28+
router: &'a IRouterInstance<P>,
29+
permit2_relayer_address: Address,
30+
permit2_addr: Address,
31+
}
32+
33+
impl<'a, P, S> TradeExecutor<'a, P, S>
2534
where
2635
P: Provider,
2736
{
28-
pub fn new(
37+
pub async fn new(
38+
signer: S,
2939
networks: &'a HashMap<u64, Network<P>>,
3040
profitability_estimator: ErasedProfitabilityEstimator,
31-
) -> Self {
32-
let routers = networks
41+
) -> anyhow::Result<Self> {
42+
let permit2_addresses: HashMap<_, _> =
43+
fetch_permit2_addresses(networks.iter()).await?.collect();
44+
let configs = networks
3345
.iter()
34-
.map(|(chain_id, net)| (*chain_id, &net.router))
35-
.collect();
36-
37-
let tokens = networks
38-
.iter()
39-
.map(|(chain_id, net)| (*chain_id, &net.tokens))
40-
.collect();
46+
.map(|(chain_id, net)| -> anyhow::Result<_> {
47+
let permit2_addr = *permit2_addresses.get(chain_id).with_context(|| {
48+
format!("failed to get permit2 address of chain {chain_id}")
49+
})?;
50+
Ok((
51+
*chain_id,
52+
ChainConfig {
53+
router: &net.router,
54+
permit2_relayer_address: net.permit2_relayer_address,
55+
permit2_addr,
56+
},
57+
))
58+
})
59+
.collect::<anyhow::Result<_>>()?;
4160

4261
let own_address = networks
4362
.iter()
4463
.next()
4564
.map(|(_, network)| network.own_addr)
4665
.expect("if we don't have a network by now, something is very wrong");
4766

48-
Self {
49-
routers,
50-
tokens,
67+
Ok(Self {
68+
signer,
69+
configs,
5170
own_address,
5271
profitability_estimator,
53-
}
72+
})
5473
}
74+
}
75+
76+
impl<'a, P, S> TradeExecutor<'a, P, S>
77+
where
78+
P: Provider,
79+
S: Signer,
80+
{
5581
pub async fn execute(
5682
&self,
5783
trades: Vec<Trade>,
@@ -64,26 +90,21 @@ where
6490
in_flight.insert(trade.request_id, ()).await;
6591

6692
// then we get the contract bindings for the destination chain
67-
let router = self
68-
.routers
93+
let config = self
94+
.configs
6995
.get(&normalise_chain_id(trade.dest_chain_id))
7096
.expect("somehow didn't have a router binding for a solved trade");
71-
let token = self
72-
.tokens
73-
.get(&normalise_chain_id(trade.dest_chain_id))
74-
.expect("somehow didn't have a token binding for a solved trade")
75-
.iter()
76-
.find(|contract| contract.address() == &trade.token_out_addr)
77-
.expect("somehow didn't have a token contract binding for a solved trade");
7897

7998
// and finally execute the trade with a timeout
8099
match timeout(
81100
timeout_config.request_timeout,
82101
execute_trade(
83102
&trade,
84-
router,
85-
token,
103+
config.router,
104+
config.permit2_relayer_address,
105+
config.permit2_addr,
86106
self.own_address,
107+
&self.signer,
87108
&self.profitability_estimator,
88109
),
89110
)
@@ -119,42 +140,42 @@ where
119140
}
120141
}
121142

122-
async fn execute_trade(
143+
async fn execute_trade<S>(
123144
trade: &Trade,
124145
router: &IRouterInstance<impl Provider>,
125-
token: &ERC20FaucetTokenInstance<impl Provider>,
146+
permit2_relayer_address: Address,
147+
permit2_addr: Address,
126148
own_addr: Address,
149+
signer: &S,
127150
profitability_estimator: &ErasedProfitabilityEstimator,
128-
) -> anyhow::Result<TxHash> {
129-
// in theory, we shouldn't need to wait until the next block because txs will be processed in nonce order
130-
// but for whatever reason this doesn't seem to be the case :(
131-
let tx = token
132-
.approve(*router.address(), trade.amount_out)
133-
.send()
134-
.await
135-
.map_err(|e| {
136-
// Try to decode it as an IERC20 error
137-
if let Some(erc20_err) = e.as_decoded_interface_error::<IERC20Errors>() {
138-
return anyhow!("erc20 contract error: {erc20_err:?}");
139-
}
140-
e.into()
141-
})
142-
.context("error approving funds")?;
143-
tx.watch().await.context("error approving funds")?;
144-
145-
let relay_tokens_call = router.relayTokens(
146-
own_addr,
147-
trade.request_id,
148-
trade.sender_addr,
149-
trade.recipient_addr,
150-
trade.token_in_addr,
151-
trade.token_out_addr,
152-
trade.amount_out,
153-
trade.src_chain_id,
154-
trade.nonce,
155-
trade.pre_hooks.to_vec(),
156-
trade.post_hooks.to_vec(),
157-
);
151+
) -> anyhow::Result<TxHash>
152+
where
153+
S: Signer,
154+
{
155+
let Permit2RelayTokensDetails {
156+
message_hash,
157+
nonce: permit_nonce,
158+
deadline: permit_deadline,
159+
} = permit2_relay_tokens_details(trade, permit2_relayer_address, own_addr, Some(permit2_addr))?;
160+
let permit2_signed_allowance = signer.sign_hash(&message_hash).await?;
161+
162+
let relay_tokens_call = router.relayTokensPermit2(RelayTokensPermit2Params {
163+
solver: own_addr,
164+
solverRefundAddress: own_addr,
165+
requestId: trade.request_id,
166+
sender: trade.sender_addr,
167+
recipient: trade.recipient_addr,
168+
tokenIn: trade.token_in_addr,
169+
tokenOut: trade.token_out_addr,
170+
amountOut: trade.amount_out,
171+
srcChainId: trade.src_chain_id,
172+
nonce: trade.nonce,
173+
permitNonce: permit_nonce,
174+
permitDeadline: permit_deadline,
175+
signature: permit2_signed_allowance.as_erc2098().into(),
176+
preHooks: trade.pre_hooks.to_vec(),
177+
postHooks: trade.post_hooks.to_vec(),
178+
});
158179

159180
let gas = relay_tokens_call
160181
.clone()
@@ -193,6 +214,10 @@ async fn execute_trade(
193214
}
194215

195216
fn decode_irouter_error(e: alloy::contract::Error) -> anyhow::Error {
217+
// Try to decode it as a permit2 error
218+
if let Some(permit2_err) = permit2::decode_error(&e) {
219+
return anyhow!("permit2 contract error: {permit2_err:?}");
220+
}
196221
// Try to decode it as an IERC20 error
197222
if let Some(erc20_err) = e.as_decoded_interface_error::<IERC20Errors>() {
198223
return anyhow!("erc20 contract error: {erc20_err:?}");

0 commit comments

Comments
 (0)