Docs/Build guides/Become a Relayer
Become a Relayer
Participate in the Loyalty Network by finding Created reward actions, reading the required native fee, submitting valid transactions with exact msg.value, and earning the relayer fee.
Relayers do not need special permission. The submitting wallet supplies requiredFee() in native ETH, while the brand signature and contract validation decide whether a Created action can execute.
Find actions#
curl "https://api.loyfin.com/operations?kind=issuance&status=pending&loyaltyId=0x4242424242424242424242424242424242424242424242424242424242424242&limit=25"Use the wire status pending to find actions the UI calls Created. Brands only need to publish valid EIP-712 signatures; relayers compete on speed, reliability, and gas discipline to execute those actions and participate in the Loyalty Network.
Submit#
import { createPublicClient, createWalletClient, http, parseAbi } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { base } from "viem/chains";
const abi = parseAbi([
"function requiredFee() view returns (uint256)",
"function issue((address issuer,address to,bytes32 loyaltyId,uint256 amount,uint256 expiresAt,uint256 deadline,bytes32 nonce,uint256 chainId,address verifyingContract,bytes32 operationHash,(bytes32 loyaltyId,string name,string symbol,string media,string description,string contractURI,string tokenURI) metadata,bytes data),bytes) payable returns (address)",
"function redeem((address issuer,address from,bytes32 loyaltyId,uint256 amount,uint256 expiresAt,uint256 deadline,bytes32 nonce,uint256 chainId,address verifyingContract,bytes32 operationHash,bytes data),bytes) payable",
"function issueClaimable((address issuer,bytes32 loyaltyId,uint256 amount,uint256 expiresAt,uint256 deadline,bytes32 nonce,uint256 chainId,address verifyingContract,bytes32 operationHash,(bytes32 loyaltyId,string name,string symbol,string media,string description,string contractURI,string tokenURI) metadata,bytes data),bytes,(address to,bytes32 claimHash,uint256 chainId,address verifyingContract,uint256 deadline),bytes) payable returns (address)",
"function redeemClaimable((address issuer,bytes32 loyaltyId,uint256 amount,uint256 expiresAt,uint256 deadline,bytes32 nonce,uint256 chainId,address verifyingContract,bytes32 operationHash,bytes data),bytes,(address from,bytes32 claimHash,uint256 chainId,address verifyingContract,uint256 deadline),bytes) payable"
]);
const privateKey = process.env.LOYFIN_RELAYER_PRIVATE_KEY;
if (!privateKey) throw new Error("Missing LOYFIN_RELAYER_PRIVATE_KEY");
const account = privateKeyToAccount(privateKey);
const publicClient = createPublicClient({ chain: base, transport: http() });
const walletClient = createWalletClient({ account, chain: base, transport: http() });
async function relayOne() {
const response = await fetch(
"https://api.loyfin.com/operations?status=pending&chainId=8453&limit=1"
);
if (!response.ok) throw new Error(await response.text());
const operation = (await response.json()).items[0];
if (!operation) return null;
const requiredFee = await publicClient.readContract({
address: operation.verifyingContract,
abi,
functionName: "requiredFee"
});
if (await publicClient.getBalance({ address: account.address }) <= requiredFee) {
throw new Error("Relayer wallet needs the required fee plus ETH for gas.");
}
const action = {
issuer: operation.issuer,
loyaltyId: operation.loyaltyId,
amount: BigInt(operation.amount),
expiresAt: BigInt(operation.expiresAt),
deadline: BigInt(operation.deadline),
nonce: operation.nonce,
chainId: BigInt(operation.chainId),
verifyingContract: operation.verifyingContract,
operationHash: operation.operationHash,
data: operation.data
};
let functionName;
let args;
if (operation.kind === "issuance") {
const issuance = { ...action, metadata: operation.metadata };
if (operation.signatureScheme === "dual") {
if (!operation.holderSignature || !operation.claimHash) return null;
functionName = "issueClaimable";
args = [issuance, operation.signature, {
to: operation.holder,
claimHash: operation.claimHash,
chainId: BigInt(operation.chainId),
verifyingContract: operation.verifyingContract,
deadline: BigInt(operation.deadline)
}, operation.holderSignature];
} else {
functionName = "issue";
args = [{ ...issuance, to: operation.holder }, operation.signature];
}
} else if (operation.signatureScheme === "dual") {
if (!operation.holderSignature || !operation.claimHash) return null;
functionName = "redeemClaimable";
args = [action, operation.signature, {
from: operation.holder,
claimHash: operation.claimHash,
chainId: BigInt(operation.chainId),
verifyingContract: operation.verifyingContract,
deadline: BigInt(operation.deadline)
}, operation.holderSignature];
} else {
functionName = "redeem";
args = [{ ...action, from: operation.holder }, operation.signature];
}
const { request } = await publicClient.simulateContract({
account,
address: operation.verifyingContract,
abi,
functionName,
args,
value: requiredFee
});
return walletClient.writeContract(request);
}
relayOne().then((hash) => console.log(hash ?? "no ready operation"));Risk checks#
- Read
requiredFee()from the factory immediately before submitting instead of hardcoding the amount. - Send that exact amount as
msg.valueand keep enough ETH for gas. - Estimate gas and compare it to the relayer fee.
- Skip expired buckets, expired action deadlines, rejected signatures, and already-submitted nonces.