Skip to content

Run a sync keeper

A script that has pools.trade pay the fee NFT doors, quotes each sync's tip with an eth_call and syncs when the tip beats the gas.

On this page

A keeper walks every revenue token, has pools.trade pay the fee NFT doors' vaults, asks each token what a sync would pay it, and syncs where the tip is worth the gas. Keepers and bots has the rules this script follows.

In the app

The token's page has a Sync button anyone can press. For a token with a fee NFT door it sends collectFees on pools.trade's fee splitter first, then sync().

The calls

  1. tokenCount() and tokenAt(i) on the tokenizer, then doorCount() and doorAt(i) on each token.
  2. For each fee NFT door, kind 2: tokenId() on the door, ownerOf(tokenId) on the PositionManager for its fee splitter, then collectFees([tokenId]) on the splitter.
  3. An eth_call of sync(): it gives back the tip a sync would pay now, which is zero unless the doors deliver at least 0.001 ETH.
  4. sync() with enough gas for every door's stipend, when the tip beats the gas. Synced(by, pulledEth, tip, bridged) records what it paid.

In rHOOKR's sync in block 93,557,029 (the rollup's block number), the doors delivered 0.053235511 ETH and the caller was paid 0.000532355 ETH, 1% of it.

In code

With client.ts and abis.ts:

import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { robinhood } from "viem/chains";
import { client, TOKENIZER } from "./client";
import { feeNftDoorAbi, feeSplitterAbi, positionManagerAbi, tokenAbi, tokenizerAbi } from "./abis";

const keeper = privateKeyToAccount(process.env.KEEPER_KEY as `0x${string}`);
const wallet = createWalletClient({ account: keeper, chain: robinhood, transport: http() });
const FEE_NFT_DOOR = 2; // DoorKind FEEB
// The pools.trade fee splitters you have checked, lower case: collectFees is called on these only.
const SPLITTERS = new Set<string>([]);

const positionManager = await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "positionManager" });
const count = await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "tokenCount" });

for (let i = 0n; i < count; i++) {
  const token = await client.readContract({ address: TOKENIZER, abi: tokenizerAbi, functionName: "tokenAt", args: [i] });
  const doorCount = await client.readContract({ address: token, abi: tokenAbi, functionName: "doorCount" });

  // 1. A fee NFT door's income waits in pools.trade's fee splitter until collectFees moves it to the vault.
  for (let d = 0n; d < doorCount; d++) {
    const door = await client.readContract({ address: token, abi: tokenAbi, functionName: "doorAt", args: [d] });
    if (door.kind !== FEE_NFT_DOOR) continue;
    const id = await client.readContract({ address: door.target, abi: feeNftDoorAbi, functionName: "tokenId" });
    const splitter = await client.readContract({ address: positionManager, abi: positionManagerAbi, functionName: "ownerOf", args: [id] });
    if (!SPLITTERS.has(splitter.toLowerCase())) continue;
    const { request } = await client.simulateContract({ account: keeper, address: splitter, abi: feeSplitterAbi, functionName: "collectFees", args: [[id]] });
    await client.waitForTransactionReceipt({ hash: await wallet.writeContract(request) });
  }

  // 2. The sync gives back its tip, so a simulation is a quote. Send it when the tip beats the gas.
  const { request, result: tip } = await client.simulateContract({ account: keeper, address: token, abi: tokenAbi, functionName: "sync" });
  if (tip === 0n) continue;
  const gas = await client.estimateContractGas({ account: keeper, address: token, abi: tokenAbi, functionName: "sync" });
  const { maxFeePerGas } = await client.estimateFeesPerGas();
  if (tip > gas * maxFeePerGas) await client.waitForTransactionReceipt({ hash: await wallet.writeContract({ ...request, gas }) });
}
  • Fill SPLITTERS with the pools.trade fee splitters you have checked: the token page's Sync calls collectFees only on the ones it knows.
  • The tip goes to the account that calls sync(). A keeper that calls through a contract of its own must accept ETH there, or the sync reverts.
  • When one door burns its whole stipend every time, syncSome(doors) with the other doors' indexes keeps the rest syncing at the same tip.
  • Never pull a door on its own before a sync: what it forwards then waits in the token as ETH already there, which the sync recognizes untipped.