Skip to content

Instantly share code, notes, and snippets.

@tinypell3ts
Created December 24, 2024 10:12
Show Gist options
  • Save tinypell3ts/573375c1fce3bec9bd0770abfb698835 to your computer and use it in GitHub Desktop.
Save tinypell3ts/573375c1fce3bec9bd0770abfb698835 to your computer and use it in GitHub Desktop.
import { Redis } from "ioredis";
import { encodeFunctionData, isAddress, parseEther, stringToHex, toHex } from "viem";
import type { Workflow } from "../@types";
import { rewardsFacetAbi } from "../abis/RewardsFacet";
import { CHAIN_IDS, EXPLORER_URLS } from "../constants/chains";
import { privyClient } from "../lib/privy";
import { publicClient } from "../lib/viem";
if (!process.env.REDIS_URL) {
throw new Error("REDIS_URL is not set");
}
const redis = new Redis(process.env.REDIS_URL);
/**
* Triggers a workflow execution for a given recipient
* @param workflow - The workflow to execute
* @param recipient - The recipient address
* @returns Object containing transaction hash and explorer link
* @throws Error if recipient is invalid or transaction fails
*/
export async function triggerWorkflow(workflow: Workflow, recipient: string) {
if (!isAddress(recipient)) {
throw new Error("Valid recipient address is required");
}
const txHash = await executeTransaction(workflow, recipient);
await sendWebhookNotification(workflow, txHash, recipient);
return {
txHash,
txLink: `${EXPLORER_URLS[CHAIN_IDS.ARBITRUM_SEPOLIA]}/tx/${txHash}`,
};
}
async function executeTransaction(workflow: Workflow, recipient: string, retryCount = 0): Promise<string> {
// Transform the human-friendly reward format into contract arguments
const args = [
workflow.rewards[0].token,
recipient,
parseEther(workflow.rewards[0].amount),
stringToHex(workflow.rewards[0].reward_id, { size: 32 }),
stringToHex("MISSION", { size: 32 }),
workflow.rewards[0].metadata_uri,
];
const data = encodeFunctionData({
abi: rewardsFacetAbi,
functionName: workflow.rewards[0].function_name,
args,
});
// Simulate the transaction first
try {
await publicClient.simulateContract({
account: workflow.owner,
address: workflow.app_id,
abi: rewardsFacetAbi,
functionName: workflow.rewards[0].function_name,
args,
});
} catch (error) {
throw new Error(`Transaction simulation failed: ${error.message}`);
}
const getNonce = async (address: string, retryCount = 0): Promise<number> => {
try {
const currentNonce = await publicClient.getTransactionCount({ address });
const redisKey = `nonce:${address}`;
// Initialize nonce in Redis if it doesn't exist
const exists = await redis.exists(redisKey);
if (!exists) {
await redis.set(redisKey, currentNonce.toString());
}
// Atomic increment and get
const nextNonce = await redis.incr(redisKey);
// If our nonce got too far ahead, reset it
if (nextNonce > currentNonce + 10) {
await redis.set(redisKey, currentNonce.toString());
return currentNonce;
}
return nextNonce - 1; // Return the pre-incremented value
} catch (error) {
if (retryCount >= 3) {
throw new Error(`Failed to get nonce after ${retryCount} retries: ${error.message}`);
}
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** retryCount));
return getNonce(address, retryCount + 1);
}
};
const [feeData, gasLimit, nonce] = await Promise.all([
publicClient.estimateFeesPerGas(),
publicClient.estimateGas({
account: workflow.owner,
to: workflow.app_id,
data,
}),
getNonce(workflow.owner),
]);
const { data: clientData } = await privyClient.walletApi.rpc({
address: workflow.owner,
chainType: "ethereum",
method: "eth_signTransaction",
params: {
transaction: {
to: workflow.app_id,
data,
from: workflow.owner,
chainId: CHAIN_IDS.ARBITRUM_SEPOLIA,
maxFeePerGas: toHex(feeData.maxFeePerGas),
maxPriorityFeePerGas: toHex(feeData.maxPriorityFeePerGas),
gasLimit: toHex(gasLimit),
nonce,
},
},
});
try {
const tx = await publicClient.sendRawTransaction({
serializedTransaction: clientData.signedTransaction,
});
return tx;
} catch (error) {
// Check if error is nonce-related and we haven't exceeded retry attempts
if (
retryCount < 5 &&
error.message &&
(error.message.includes("nonce too low") || error.message.includes("nonce too high"))
) {
console.log(`Nonce error detected, retrying (attempt ${retryCount + 1}/5)...`);
// Wait with exponential backoff before retrying
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, retryCount)));
// Reset Redis nonce to force a fresh fetch
await redis.del(`nonce:${workflow.owner}`);
// Retry the entire transaction
return executeTransaction(workflow, recipient, retryCount + 1);
}
// If we've exhausted retries or it's a different error, throw it
throw error;
}
}
async function sendWebhookNotification(workflow: Workflow, txHash: string, recipient: string) {
if (!workflow.webhook) return;
try {
const payload: WebhookPayload = {
workflow_id: workflow.id,
workflow_name: workflow.name,
transaction_hash: txHash,
transaction_url: `${EXPLORER_URLS[CHAIN_IDS.ARBITRUM_SEPOLIA]}/tx/${txHash}`,
recipient,
timestamp: new Date().toISOString(),
};
await fetch(workflow.webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} catch (error) {
console.error("Webhook notification failed:", error);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment