-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: gelato and local signers #1346
Changes from all commits
c79fc21
b50d215
91f4cc2
703ef6e
2110f3e
5c6ea57
52b223e
0a70028
8db5ebd
382920f
c9998fa
3029656
fb3d672
a48cf21
2a19907
588abc7
77a0404
10b0173
df1f4c6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { Client } from "@upstash/qstash"; | ||
|
||
import { RelayRequest, RelayStrategy, RelayStrategyName } from "./_types"; | ||
import { resolveVercelEndpoint } from "../_utils"; | ||
|
||
const client = new Client({ | ||
token: process.env.QSTASH_TOKEN!, | ||
}); | ||
|
||
export async function pushRelayRequestToQueue({ | ||
request, | ||
strategy, | ||
}: { | ||
request: RelayRequest; | ||
strategy: RelayStrategy; | ||
}) { | ||
const strategyName = strategy.strategyName; | ||
const queue = getRelayRequestQueue(strategyName, request.chainId); | ||
await queue.upsert({ | ||
parallelism: strategy.queueParallelism, | ||
}); | ||
|
||
const baseUrl = resolveVercelEndpoint(true); | ||
const response = await queue.enqueueJSON({ | ||
retries: 3, | ||
contentBasedDeduplication: true, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this solve nonce collision? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea that's the idea. by using a queue and setting parallelism to the number of signers we have, we should be able to mitigate nonce collisions on a single chain. |
||
headers: new Headers({ | ||
"Retry-After": "1", | ||
}), | ||
url: `${baseUrl}/api/relay/jobs/process`, | ||
body: { | ||
request, | ||
strategyName, | ||
}, | ||
}); | ||
return response; | ||
} | ||
|
||
function getRelayRequestQueue( | ||
strategyName: RelayStrategyName, | ||
chainId: number | ||
) { | ||
return client.queue({ | ||
queueName: `relay-request-queue-${chainId}-${strategyName}`, | ||
}); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import axios from "axios"; | ||
import { RelayRequest, RelayStrategy } from "../_types"; | ||
import { encodeCalldataForRelayRequest } from "../_utils"; | ||
|
||
const GELATO_API_KEY = process.env.GELATO_API_KEY; | ||
|
||
export function getGelatoStrategy(): RelayStrategy { | ||
return { | ||
strategyName: "gelato", | ||
queueParallelism: 2, | ||
relay: async (request: RelayRequest) => { | ||
const encodedCalldata = encodeCalldataForRelayRequest(request); | ||
|
||
const taskId = await relayWithGelatoApi({ | ||
chainId: request.chainId, | ||
target: request.to, | ||
data: encodedCalldata, | ||
}); | ||
|
||
let txHash: string | undefined; | ||
|
||
while (true) { | ||
const taskStatus = await getGelatoTaskStatus(taskId); | ||
|
||
if ( | ||
["Cancelled", "NotFound", "ExecReverted", "Blacklisted"].includes( | ||
taskStatus.taskState | ||
) | ||
) { | ||
throw new Error( | ||
`Can not relay request via Gelato due to task state ${taskStatus.taskState}` | ||
); | ||
} | ||
|
||
if (taskStatus.transactionHash) { | ||
txHash = taskStatus.transactionHash; | ||
break; | ||
} | ||
|
||
await new Promise((resolve) => setTimeout(resolve, 1_000)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: do you think there's value in setting the polling interval to the block time for the chain? or maybe make this an ENV? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hmm yea block time could make sense actually. a bit hesitant on using an env var though |
||
} | ||
|
||
return txHash; | ||
}, | ||
}; | ||
} | ||
|
||
const gelatoBaseUrl = "https://api.gelato.digital"; | ||
|
||
async function relayWithGelatoApi({ | ||
chainId, | ||
target, | ||
data, | ||
}: { | ||
chainId: number; | ||
target: string; | ||
data: string; | ||
}) { | ||
if (!GELATO_API_KEY) { | ||
throw new Error("Can not call Gelato API: key is not set"); | ||
} | ||
|
||
const response = await axios.post( | ||
`${gelatoBaseUrl}/relays/v2/sponsored-call`, | ||
{ | ||
chainId, | ||
target, | ||
data, | ||
sponsorApiKey: GELATO_API_KEY, | ||
} | ||
); | ||
|
||
return response.data.taskId as string; | ||
} | ||
|
||
async function getGelatoTaskStatus(taskId: string) { | ||
const response = await axios.get<{ | ||
task: { | ||
taskState: | ||
| "CheckPending" | ||
| "ExecPending" | ||
| "ExecSuccess" | ||
| "ExecReverted" | ||
| "WaitingForConfirmation" | ||
| "Blacklisted" | ||
| "Cancelled" | ||
| "NotFound"; | ||
transactionHash?: string; | ||
}; | ||
}>(`${gelatoBaseUrl}/tasks/status/${taskId}`); | ||
return response.data.task; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
import { RelayStrategy, RelayStrategyName } from "../_types"; | ||
import { getGelatoStrategy } from "./gelato"; | ||
import { getLocalSignersStrategy } from "./local-signers"; | ||
|
||
const gelatoStrategy = getGelatoStrategy(); | ||
const localSignersStrategy = getLocalSignersStrategy(); | ||
|
||
export const strategiesByName = { | ||
[gelatoStrategy.strategyName]: gelatoStrategy, | ||
[localSignersStrategy.strategyName]: localSignersStrategy, | ||
} as Record<RelayStrategyName, RelayStrategy>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,92 @@ | ||
import { Wallet, utils } from "ethers"; | ||
|
||
import { RelayRequest, RelayStrategy } from "../_types"; | ||
import { encodeCalldataForRelayRequest } from "../_utils"; | ||
import { redisCache } from "../../_cache"; | ||
import { getProvider } from "../../_utils"; | ||
|
||
const localSignerPrivateKeys = | ||
process.env.LOCAL_SIGNER_PRIVATE_KEYS!.split(","); | ||
const balanceAlertThreshold = utils.parseEther("0.000001"); // TODO: Refine value | ||
|
||
export function getLocalSignersStrategy(): RelayStrategy { | ||
return { | ||
strategyName: "local-signers", | ||
queueParallelism: 1, // TODO: Should be dynamic based on the number of local signers | ||
relay: async (request: RelayRequest) => { | ||
const encodedCalldata = encodeCalldataForRelayRequest(request); | ||
|
||
if (localSignerPrivateKeys.length === 0) { | ||
throw new Error( | ||
"Can not relay tx via local signers: No local signers found" | ||
); | ||
} | ||
|
||
for (const signerPrivateKey of localSignerPrivateKeys) { | ||
const provider = getProvider(request.chainId); | ||
const wallet = new Wallet(signerPrivateKey, provider); | ||
try { | ||
await lockSigner(wallet.address, request.chainId); | ||
|
||
const balance = await wallet.getBalance(); | ||
if (balance.lt(balanceAlertThreshold)) { | ||
// TODO: Send PD alert | ||
} | ||
|
||
const txRequest = { | ||
chainId: request.chainId, | ||
to: request.to, | ||
data: encodedCalldata, | ||
from: wallet.address, | ||
}; | ||
const tx = await wallet.sendTransaction(txRequest); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. would it make sense to simulate this tx first? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
const receipt = await tx.wait(); | ||
return receipt.transactionHash; | ||
} catch (error) { | ||
if (error instanceof SignerLockedError) { | ||
continue; | ||
} | ||
throw error; | ||
} finally { | ||
await unlockSigner(wallet.address, request.chainId); | ||
} | ||
} | ||
|
||
throw new Error( | ||
"Can not relay tx via local signers: All local signers are locked" | ||
); | ||
}, | ||
}; | ||
} | ||
|
||
async function lockSigner(signerAddress: string, chainId: number) { | ||
const lockKey = getLockKey(signerAddress, chainId); | ||
const lockValue = await redisCache.get(lockKey); | ||
|
||
if (lockValue) { | ||
throw new SignerLockedError(signerAddress, chainId); | ||
} | ||
|
||
await redisCache.set(lockKey, "true", 30); | ||
} | ||
|
||
async function unlockSigner(signerAddress: string, chainId: number) { | ||
const lockKey = getLockKey(signerAddress, chainId); | ||
|
||
const lockValue = await redisCache.get(lockKey); | ||
if (!lockValue) { | ||
return; | ||
} | ||
|
||
await redisCache.del(lockKey); | ||
} | ||
|
||
function getLockKey(signerAddress: string, chainId: number) { | ||
return `signer-lock:${signerAddress}:${chainId}`; | ||
} | ||
|
||
class SignerLockedError extends Error { | ||
constructor(signerAddress: string, chainId: number) { | ||
super(`Signer ${signerAddress} on chain ${chainId} is already locked`); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import { validateMethodArgs } from "./_utils"; | ||
|
||
export type RelayStrategyName = "gelato" | "local-signers"; | ||
|
||
export type RelayRequest = { | ||
chainId: number; | ||
to: string; | ||
methodNameAndArgs: ReturnType<typeof validateMethodArgs>; | ||
signatures: { | ||
permit: string; | ||
deposit: string; | ||
}; | ||
}; | ||
|
||
export type RelayStrategy = { | ||
strategyName: RelayStrategyName; | ||
queueParallelism: number; | ||
relay: (request: RelayRequest) => Promise<string>; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is each queue/strategy using different EOAs? If not, how do we avoid nonce collisions between txns submitted within the same block between these queues?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it depends on the strategy. for example, if using Gelato, we don't need to handle nonce collisions because they take care of it. if using local signers, then we need to implement a mechanism/handler that's able to prevent nonce collisions.