SVR Searcher Onboarding: Atlas (Base, Arbitrum, BNB Chain)

Chainlink Smart Value Recapture (SVR) has expanded to Base, Arbitrum, and BNB Chain with a new auction system, Atlas. This guide provides existing Chainlink SVR searchers with the technical specifications and integration requirements for participating in all non-ETH mainnet SVR auctions.

Core Components

  • SVR Price Feeds: Modified Chainlink aggregators used for SVR auction transactions.
  • Atlas: A set of smart contracts and off-chain auction infrastructure.
  • Solver Network: Network of liquidators.

Terminology

The terms Searcher and Solver are used interchangeably throughout this guide.

Operational Recommendations

Recommended native token amounts for bonding:

  • Base and Arbitrum: 0.1 ETH
  • BNB Chain: 1 BNB

Supported Protocols

  • AAVE
  • Compound
  • Venus

Contract References

The following contracts are deployed at version v1.6.4.

ContractAddress
Atlas (v1.6.4)0x583dcFef0D240DC80753F0F0B26513feE27D9B77
DappControl0xa5E1a36938769cbd5a26f5e19D8FCB379f597c83

Feed Aggregator Addresses

AssetTypeAggregator Address
AAVE-USDAave-SVR0x4b6F092e0e13B94fFAF2C59aAbDEb85a5342e9C1
BTC-USDAave-SVR0x137233996b6586a110bb7a753248e26CC0307b1B
BTC-USDSVR0xeb3Ad4395924b76eB64b3d6aBabA0B62875b1A1f
ETH-USDAave-SVR0xD772F6D9b7A35cb96fDdFE569964ab1C05017BF9
ETH-USDSVR0x83f3425A5b32655DC645f7f4e422DD60E9741794
EURC-USDAave-SVR0x042fc0bA0684eDE99b751b0931B6D1F590758994
EURC-USDSVR0xa9c061D7f744796b29025a50Ec7eb55971ca587d
GHO-USDAave-SVR0x68358e8E49138E89af9d3E55Cc66Bc44f6025d0f
USDC-USDAave-SVR0x0fB39aE1d48Faf8CA5ea8DbF7e134e07386A7877
USDC-USDSVR0x7F73eB1ae276Ef4d155f9Cf9a81986DB343CF1CA
USDT-USDAave-SVR0xA7f8123688b9d7cf2f91cb926B2a3f44Cc229d0A
USDT-USDSVR0xcc7cC8513BD52E443cEA0E63599d47Db56149817
ezETH-ETH Exchange RateAave-SVR0x9eF2826f41563b1375a3188C33040E697981F7C5
LBTC-BTC Exchange RateAave-SVR0x8Bd94C7616fa88cc2ab59A66540bcEaf034ef304
rsETH-ETH Exchange RateAave-SVR0xf1A51Dc55e6707F5aEE7D426110CA50119A5314B
weETH-eETH Exchange RateAave-SVR0x6D96F90d0Db82903406E97Dda54969EA58a82ec8
wstETH-stETH Exchange RateAave-SVR0x926E0bbA53F2deEb8a2BD0138fDd3Dc675830399

Guide

High-Level

Atlas is conceptually similar to MEV-Share, built to work on any EVM chain without requiring block builders.

Instead of relying on block builders to bundle together the oracle update and the liquidation transaction, the oracle update and the liquidation are both represented as EIP-712 messages and are bundled together into a single atomic EVM transaction similar to multi-call or ERC-4337.

Endpoints

EndpointURL
Bid endpointhttps://svr-bid-endpoint.chain.link/
Searcher WebSocketwss://svr-bid-endpoint.chain.link/ws/solver

Simulation and Execution

SolverOps that fail simulation will not show up on-chain.

Example transaction: BscScan 0x6194065e

Signing the Payload

The payload that needs to be signed is the Ethereum message (EIP-191) in the following format:

<auctionID>:<userOperationHash>:<solverOperationFrom>

Note the colon (:) between each field. Most libraries provide built-in helpers for signing messages:

Example signed payload:

89599d40-decb-4f1c-97bb-c3e101d790af:0x6044c22ab257659b74b1eb4cf2f8f65e0bcc2d9fe832279efb42a6700873fa74:0x5003676390dfe662Af408Eb0bf13e182aDcaCE0a

WebSocket Subscription

Connect to the searcher WebSocket endpoint and subscribe to user operations:

Subscription payload:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "solver_subscribe",
  "params": ["userOperations"]
}

Example response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": "5ed17bf8-ed27-4625-97ef-447554594a3c"
}

Example notification:

{
  "jsonrpc": "2.0",
  "method": "solver_subscription",
  "params": {
    "subscription": "5ed17bf8-ed27-4625-97ef-447554594a3c",
    "result": {
      "auction_id": "1bc9a4ce-4fcf-4eb1-8632-959e2273953e",
      "partial_user_operation": {
        "chainId": "0x2105",
        "userOpHash": "0x21d03d618fa4fb9166ae91f199b774b64390b8bf6ecdd2ae4637096e7b60e5e3",
        "to": "0xb15BdDC2180cF83B3ECb1eDE074a177c9C7Acc5f",
        "gas": "0x4e20",
        "maxFeePerGas": "0x4c4b40",
        "deadline": "0x19d8c75",
        "dapp": "0x43b4aae0f98fc9ebd86a1e9496cdb9d7208ee55b",
        "control": "0x43b4aae0f98fc9ebd86a1e9496cdb9d7208ee55b",
        "value": "0x0",
        "data": "0x1ad6fbc3",
        "from": "0xfc8b8974fc3adb8281a6c4c38d7cc895769a8568"
      }
    }
  }
}

Submitting a Solution

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "solver_submitSolverOperation",
  "params": [
    {
      "auction_id": "1bc9a4ce-4fcf-4eb1-8632-959e2273953e",
      "auction_solution": {
        "from": "0x377136613944bdd5d9f0db22987b7432e76c354f",
        "to": "0xb15BdDC2180cF83B3ECb1eDE074a177c9C7Acc5f",
        "value": "0x0",
        "gas": "0x7a120",
        "maxFeePerGas": "0xb71b00",
        "deadline": "0x12aad5bd",
        "solver": "0x7ff1b456058af5e8f36c4e5c29049f40c0aa945c",
        "control": "0x43b4aae0f98fc9ebd86a1e9496cdb9d7208ee55b",
        "userOpHash": "0xbf8c8c00c288558cc0204e8e802a2045655804441c576fec386b888e8619052a",
        "bidToken": "0xaf88d065e77c8cc2239327c5edb3a432268e5831",
        "bidAmount": "0x3b9aca00",
        "data": "0x1a2b3c4d",
        "signature": "0x786a3a67058a02307066747ebaf09a5704b6d3fd4f891d9390e437530b68b7f91bcd4c12e09035917ca22b40069663c644b1d9eb74b4808028e086484fb1b27b1c"
      }
    }
  ]
}

Example response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": "null"
}

Bond Bridged ETH to the Auction Contract

About Bonding

While Chainlink oracles submit transactions on-chain and advance the associated gas costs, all searchers are required to atomically pay the gas costs of their liquidation and the oracle update within the transaction.

Searchers do not need to explicitly send a payment. Instead, gas costs are automatically deducted from the searcher's bonded balance.

Gas Repayment Rules

The amount of gas repaid by a searcher depends on the outcome of the searcher operation:

OutcomeGas Charged
Searcher operation executed successfullyGas consumed by both the searcher operation and user operation is charged to the searcher.
Searcher operation executed but failed (searcher responsibility)Gas consumed by the searcher operation is charged to the searcher.
Searcher operation executed but failed (bundler responsibility)No gas charge applied to the searcher.
Searcher operation not executedNo gas charge applied to the searcher.

Bonding and Unbonding Steps

1. Select an Account

The account (EOA) bonding ETH must be the same account that will sign the searcher operations (defined in the from field of the operation).

2. Deposit and Bond

Call the depositAndBond function on the Atlas contract:

interface IAtlas {
    function depositAndBond(uint256 amountToBond) external payable;
}

address atlasAddress = address(0x8e098Dfd60aEC9bCf07fd3cA5933e9F22b1b4A0d);
uint256 amountToBond = 1e18;

IAtlas(atlasAddress).depositAndBond{ value: amountToBond }(amountToBond);

3. Unbonding

Searchers can recover their funds in two steps:

  1. Call unbond and wait for the unbonding period
  2. Call redeem
interface IAtlas {
    function unbond(uint256 amount) external;
    function redeem(uint256 amount) external;
    function ESCROW_DURATION() external view returns (uint256);
}

address atlasAddress = address(0x8e098Dfd60aEC9bCf07fd3cA5933e9F22b1b4A0d);
uint256 amountToUnbond = 1e18;

IAtlas(atlasAddress).unbond(amountToUnbond);

uint256 escrowDuration = IAtlas(atlasAddress).ESCROW_DURATION();

// Must wait `escrowDuration` blocks before calling `redeem`.

IAtlas(atlasAddress).redeem(amountToUnbond);

How to Deploy a Searcher Contract

Searcher execution logic must be held in a smart contract that will be called by the system during transaction execution. The searcher must pay their bid before the end of their execution.

1. Requirements

The searcher's contract must define the following callback function:

function atlasSolverCall(
    address solverOpFrom,
    address executionEnvironment,
    address bidToken,
    uint256 bidAmount,
    bytes calldata solverOpData,
    bytes calldata forwardedData
) external payable

This function is called by Atlas during transaction execution. Parameter breakdown:

ParameterDescription
solverOpFromThe from field of the searcher/solver operation being executed (for safety checks).
executionEnvironmentA unique contract generated for the user/dAppControl pair. The bid must be paid to this address.
bidTokenThe bid token. address(0) refers to ETH.
bidAmountThe bid amount to be paid to the execution environment.
solverOpDataThe data passed by the searcher/solver in the searcher operation's data field.
forwardedDataThe data returned by previous execution steps (pre-ops and user operation) if enabled by the dApp.

Before the end of atlasSolverCall execution, the solver must:

  • Pay bidAmount to the executionEnvironment address in bidToken currency, or face revert.
  • Pay its gas consumption by calling the Atlas reconcile function.
  • Ensure the caller is the Atlas contract.

2. Easy Integration

Solvers can inherit their contract from the official SolverBase contract. This contract defines the atlasSolverCall function, handles safety checks, bid payments, and gas liability payments, so the solver can focus on its custom execution logic only.

Inheriting from SolverBase:

pragma solidity ^0.8.22;

import {SolverBase} from "@atlas/solver/SolverBase.sol";

contract DemoSolver is SolverBase {
    /*
     * @notice Constructor
     * @param weth_ The address of the WETH token
     * @param atlas_ The address of Atlas
     */
    constructor(address weth_, address atlas_) SolverBase(weth_, atlas_, msg.sender) {}

    function myMevFunction(address myParam1, uint256 myParam2) external {
        // Solver MEV logic goes here

        // At the end of execution, profit should be held in this same contract
        // The `payBids` modifier in `SolverBase` will take care of paying what is owed
    }
}

Once deployed, the contract address must be referenced in the searcher operation's solver field. The data field must be the encoded myMevFunction call:

address myParam1 = address(0x01);
uint256 myParam2 = 999;

bytes calldata solverOperationData = abi.encodeCall(DemoSolver.myMevFunction, (myParam1, myParam2));

How to Participate in Auctions

After bonding atlETH and deploying their smart contract, solvers are finally able to participate in auctions by communicating with the searcher gateway.

This guide uses Go and the Atlas Go SDK.

1. Communicate with the Searcher Gateway

Searchers communicate with the system through the Searcher Gateway, which serves as the entry point for reading user operations and submitting solutions.

The gateway exposes a compliant JSON RPC API, so we can use the geth RPC client. It is fully compatible with the Atlas API, so all existing Atlas integrations work seamlessly through the gateway. The following code connects to the searcher gateway and defines an event loop where user operation notifications will be received.

It validates the notification and builds a solver operation by calling the isOfInterest and buildSolution functions, which are defined in the next sections. It then sends the solution back to the searcher gateway.

// connect.go
package searcher_gateway

import (
	"context"
	"time"

	"github.com/FastLane-Labs/atlas-sdk-go/types"
	"github.com/ethereum/go-ethereum/rpc"
	"github.com/gorilla/websocket"
)

const (
	solverNamespace                 = "solver"
	userOperationsSubscriptionTopic = "userOperations"
	submitSolverOperationMethod     = "solver_submitSolverOperation"
	searcherGatewayUrl              = "wss://svr-bid-endpoint.chain.link/ws/solver"
)

// Notifications are received in this format
type UserOperationNotification struct {
	AuctionId            string                         `json:"auction_id"`
	PartialUserOperation *types.UserOperationPartialRaw `json:"partial_user_operation"`
}

func runSearcherGateway() {
	// Set bigger buffer sizes for the websocket connection
	dialerOption := rpc.WithWebsocketDialer(websocket.Dialer{
		ReadBufferSize:  1024 * 1024,
		WriteBufferSize: 1024 * 1024,
	})

	// Dial the searcher gateway
	searcherGatewayClient, err := rpc.DialOptions(context.TODO(), searcherGatewayUrl, dialerOption)
	if err != nil {
		panic(err)
	}

	// Create a channel to receive notifications
	var (
		uoChan chan *UserOperationNotification
		sub    *rpc.ClientSubscription
	)

	// Subscribe/Resubscribe to user operations notifications
	subscribe := func() {
		for {
			uoChan = make(chan *UserOperationNotification, 32)

			sub, err = searcherGatewayClient.Subscribe(context.TODO(), solverNamespace, uoChan, userOperationsSubscriptionTopic)
			if err != nil {
				// Failed to subscribe, wait a bit and retry
				time.Sleep(1 * time.Second)
				continue
			}

			break
		}
	}

	// Main loop
	for {
		select {
		case <-sub.Err():
			// If the subscription errors, resubscribe
			subscribe()

		case n := <-uoChan:
			// Received a notification, check if it's of interest
			if !isOfInterest(n) {
				continue
			}

			// Build a solution and submit it
			solution, err := buildSolution(n)
			if err != nil {
				panic(err)
			}

			// No error means the submission was successful
			err = searcherGatewayClient.CallContext(context.TODO(), nil, submitSolverOperationMethod, solution)
			if err != nil {
				panic(err)
			}
		}
	}
}

2. Build Solver Operations

Here is an example on how to construct a solver operation.

// solution.go
package searcher_gateway

import (
	"math/big"

	"github.com/FastLane-Labs/atlas-sdk-go/config"
	"github.com/FastLane-Labs/atlas-sdk-go/types"
	"github.com/FastLane-Labs/atlas-sdk-go/utils"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/crypto"
)

// Solutions are sent in this format
type Solution struct {
	AuctionId       string                    `json:"auction_id"`
	AuctionSolution *types.SolverOperationRaw `json:"auction_solution"`
}

func buildSolution(n *UserOperationNotification) (*Solution, error) {
	// Get the solver private key
	solverPk, err := crypto.HexToECDSA("0xmySolverPrivateKey")
	if err != nil {
		return nil, err
	}

	var (
		// This is the address of the solver account
		// This account must have bonded atlETH on the Atlas contract
		solverAccount = crypto.PubkeyToAddress(solverPk.PublicKey)

		// This is the address of the solver contract
		// This contract must have the `atlasSolverCall` defined
		solverContractAddress = common.HexToAddress("0xmySolverContractAddress")

		// This contains the data to be passed to the `atlasSolverCall`
		// This should contain the solver execution logic
		solverData = common.FromHex("0xmySolverData")
	)

	// Build the solver operation
	solverOperation := &types.SolverOperation{
		// The solver account (and signer of this operation)
		From: solverAccount,

		// The Atlas address, here we're retrieving it from the user operation
		To: n.PartialUserOperation.To,

		// The gas limit for the solver operation
		Gas: big.NewInt(500_000),

		// The max fee per gas for the solver operation
		// It must be equal to or higher than the user operation's max fee per gas
		// Here we match the user operation's value
		MaxFeePerGas: new(big.Int).Set(n.PartialUserOperation.MaxFeePerGas.ToInt()),

		// The deadline for the solver operation (block number)
		// Here we match the user operation's value
		Deadline: new(big.Int).Set(n.PartialUserOperation.Deadline.ToInt()),

		// The solver contract address
		Solver: solverContractAddress,

		// The `dAppControl` (module) address
		// Here we're retrieving it from the user operation
		Control: n.PartialUserOperation.Control,

		// The user operation hash
		UserOpHash: n.PartialUserOperation.UserOpHash,

		// The bid token, address(0) usually stands for ETH
		BidToken: common.Address{},

		// The bid amount
		BidAmount: big.NewInt(200_000),

		// The data to be passed to the solver contract
		Data: solverData,
	}

	// To sign an operation with the SDK, we need to specify the chain ID and the Atlas version

	// Getting the chain ID from the notification
	chainId := n.PartialUserOperation.ChainId.ToInt().Uint64()

	// Getting the Atlas version from the Atlas address specified in the user operation
	atlasVersion, err := config.GetVersionFromAtlasAddress(chainId, n.PartialUserOperation.To)
	if err != nil {
		return nil, err
	}

	// Getting the hash of the solver operation (this is the payload that will be signed)
	hash, err := solverOperation.Hash(chainId, &atlasVersion)
	if err != nil {
		return nil, err
	}

	// Signing the hash with the solver private key
	signature, err := utils.SignMessage(hash.Bytes(), solverPk)
	if err != nil {
		return nil, err
	}

	// Setting the signature to the solver operation
	solverOperation.Signature = signature

	// Returning the solution
	return &Solution{
		// The auction ID, as communicated in the notification
		AuctionId: n.AuctionId,

		// The solver operation, serialized
		AuctionSolution: solverOperation.EncodeToRaw(),
	}, nil
}

3. Filter User Operations

In the above example, we do not filter user operations. The searcher gateway will broadcast every user operation it gets. It's necessary to discard operations that the solver can't bid on.

// filter.go
package searcher_gateway

import (
	"math/big"

	"github.com/ethereum/go-ethereum/common"
)

var (
	// Filtering notifications for this chain ID only
	chainId = big.NewInt(8453)

	// Filtering notifications for the Chainlink dApp control (module) address only
	dAppControlAddress = common.HexToAddress("0xe15BBa987C002ecc3586e81244517877D294d291")
)

// This function returns true if the notification is of interest and false
// if it should be discarded
func isOfInterest(n *UserOperationNotification) bool {
	if n.PartialUserOperation.ChainId.ToInt().Cmp(chainId) != 0 {
		return false
	}

	if n.PartialUserOperation.Control != dAppControlAddress {
		return false
	}

	return true
}

4. Decode User Operations

When a notification is of interest, and before building a solver operation, decode the received user operation to ensure you are able to bid on it.

To find out what a user operation is intending to do, inspect its dapp and hints fields:

  • userOperation.dapp: The contract address that the user will call.
  • userOperation.hints: Three key pieces of information that clarify which feed is being updated and at what price:
    • aggregator: The contract address of the feed.
    • medianPrice: The median reported price, the final updated feed price.
    • rawReport: The full report sent to the aggregator. This is included for visibility, but isn't strictly needed since we already have the medianPrice.

Once you've identified a potential SVR feed update transmission with the isOfInterest() method, it's important to know how to extract:

  • The feed address: This is required to determine which SVR data feed is being updated.
  • The new price data: This is essential for determining if profitable liquidation opportunities exist.

The decoding process relies on recognizing that the transaction ultimately calls the transmitSecondary function, which updates the price using the report data:

transmitSecondary(
  bytes32[3] calldata reportContext,
  bytes calldata report,
  bytes32[] calldata rs,
  bytes32[] calldata ss,
  bytes32 rawVs
)

The report parameter has the following structure:

struct Report {
  uint32 observationsTimestamp;
  bytes32 observers;
  int192[] observations;
  int192 juelsPerFeeCoin;
}

The median price, which is used to update the value, could be extracted from the report structure (rawReport) by selecting the median observation. If the count is odd, use the central element; if the count is even, take the element at index length / 2 rather than averaging the two central values.

This isn't necessary because the median price has already been pre-calculated and is available as medianPrice. The following code demonstrates how to retrieve the feed address and decode the feed price:

func GetFeedAddressAndPrice(n *UserOperationNotification) (common.Address, *big.Int, error) {
	feedAddress := common.HexToAddress(n.PartialUserOperation.Hints["aggregator"].(string))

	decodedPrice, err := hex.DecodeString(strings.TrimPrefix(strings.ToLower(n.PartialUserOperation.Hints["medianPrice"].(string)), "0x"))
	if err != nil {
		return common.Address{}, nil, fmt.Errorf("failed to decode medianPrice hint: %w", err)
	}

	feedPrice := new(big.Int).SetBytes(decodedPrice)

	return feedAddress, feedPrice, nil
}

Tracing Solver Operation Results

FastLane provides a query API for solvers to trace what happened to their solver operation. Results are cached for 1 hour. Currently with an auction time of 2s, you should wait ~3 seconds before querying this API for a given SolverOp.

URL: https://solver-query-api-fra.fastlane-labs.xyz/

Example request:

{
  "jsonrpc": "2.0",
  "id": "sample-id",
  "method": "solver_getSolverOperationResult",
  "params": [
    {
      "auctionId": "abcd-1234",
      "userOperationHash": "0x1234...",
      "solverOperationFrom": "0xabcd...",
      "signature": "0xabcd..."
    }
  ]
}

Example response:

{
  "jsonrpc": "2.0",
  "id": "sample-id",
  "result": {
    "auctionId": "abcd-1234",
    "solverOperationFrom": "0xabcd...",
    "result": "included"
  }
}

Common Mistakes

Not paying the bid to the ExecutionEnvironment contract

This won't happen if you inherit from SolverBase.

Solver operation arrives too late

If the solver operation arrives after the auction duration has elapsed, it will not be included. The query API will communicate the error "solver operation not found".

Not enough bonded atlEth

Read the bonding guide to learn how to bond atlETH. This error will be communicated in the query API response.

Solver operation signer is different from the owner of the Solver Contract

SolverBase has an owner, and the solver operation will revert if the signer differs from the owner. The query API will communicate error SolverOpReverted.

Incorrect solver signature

The solver signature is an EIP-712 signature. The following domain should be used:

{
    name: "AtlasVerification",
    version: "<version>", // Version of the Atlas Verification contract
    chainId: 1, // chain id
    verifyingContract: "<atlas_verification_address>", // Atlas Verification contract address
}

An incorrect signature will be communicated by the query API.

Incorrect Bid Token

The bid token is described in the dapp control contract (userOperation.control). If set to address(0), the bid token is the chain's native token. Paying with the wrong token will result in a SolverOpReverted error.

A reverting solver operation

If the solver operation reverts for any reason, the query API will communicate error SolverOpReverted.

What's next

Get the latest Chainlink content straight to your inbox.