Skip to content

Commit

Permalink
feat: gelato and local signers (#1346)
Browse files Browse the repository at this point in the history
* refactor: deposit and swap types

* feat: add base /relay handler

* feat: add qstash and gelato strategy

* re-introduce requestHash

* fixup

* fixup

* fix: addresses

* fixup

* fix: process message

* feat: local signers strategy

* fixup

* fixup

* fixup

* fix: local signer strategy

* cleanup

* fix: error message

* fixup

* review requests
  • Loading branch information
dohaki authored Dec 30, 2024
1 parent d043df0 commit 3e650d2
Show file tree
Hide file tree
Showing 21 changed files with 805 additions and 478 deletions.
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);
await queue.upsert({
parallelism: strategy.queueParallelism,
});

const baseUrl = resolveVercelEndpoint(true);
const response = await queue.enqueueJSON({
retries: 3,
contentBasedDeduplication: true,
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));
}

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);
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

0 comments on commit 3e650d2

Please sign in to comment.