Skip to content
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

Merged
merged 19 commits into from
Dec 30, 2024
7 changes: 7 additions & 0 deletions api/_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export class RedisCache implements interfaces.CachingMechanismInterface {
throw error;
}
}

async del(key: string) {
if (!this.client) {
return;
}
await this.client.del(key);
}
}

export const redisCache = new RedisCache();
Expand Down
38 changes: 38 additions & 0 deletions api/_spoke-pool-periphery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,41 @@ export async function getSwapAndDepositTypedData(params: {
},
};
}

export function encodeDepositWithPermitCalldata(args: {
signatureOwner: string;
depositData: SpokePoolV3PeripheryInterface.DepositDataStruct;
deadline: number;
permitSignature: string;
depositDataSignature: string;
}) {
return SpokePoolV3Periphery__factory.createInterface().encodeFunctionData(
"depositWithPermit",
[
args.signatureOwner,
args.depositData,
args.deadline,
args.permitSignature,
args.depositDataSignature,
]
);
}

export function encodeSwapAndBridgeWithPermitCalldata(args: {
signatureOwner: string;
swapAndDepositData: SpokePoolV3PeripheryInterface.SwapAndDepositDataStruct;
deadline: number;
permitSignature: string;
swapAndDepositDataSignature: string;
}) {
return SpokePoolV3Periphery__factory.createInterface().encodeFunctionData(
"swapAndBridgeWithPermit",
[
args.signatureOwner,
args.swapAndDepositData,
args.deadline,
args.permitSignature,
args.swapAndDepositDataSignature,
]
);
}
4 changes: 2 additions & 2 deletions api/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ export const getLogger = (): LoggingUtility => {
* Resolves the current vercel endpoint dynamically
* @returns A valid URL of the current endpoint in vercel
*/
export const resolveVercelEndpoint = () => {
if (process.env.REACT_APP_VERCEL_API_BASE_URL_OVERRIDE) {
export const resolveVercelEndpoint = (omitOverride = false) => {
if (!omitOverride && process.env.REACT_APP_VERCEL_API_BASE_URL_OVERRIDE) {
return process.env.REACT_APP_VERCEL_API_BASE_URL_OVERRIDE;
}
const url = process.env.VERCEL_URL ?? "across.to";
Expand Down
46 changes: 46 additions & 0 deletions api/relay/_queue.ts
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);
Copy link
Contributor

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?

Copy link
Contributor Author

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.

await queue.upsert({
parallelism: strategy.queueParallelism,
});

const baseUrl = resolveVercelEndpoint(true);
const response = await queue.enqueueJSON({
retries: 3,
contentBasedDeduplication: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this solve nonce collision?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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}`,
});
}
92 changes: 92 additions & 0 deletions api/relay/_strategies/gelato.ts
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));
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
}
11 changes: 11 additions & 0 deletions api/relay/_strategies/index.ts
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>;
92 changes: 92 additions & 0 deletions api/relay/_strategies/local-signers.ts
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to simulate this tx first?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendTransaction actually does an internal estimateGas call already if no gasLimit is provided

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`);
}
}
19 changes: 19 additions & 0 deletions api/relay/_types.ts
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>;
};
Loading
Loading