Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/cowprotocol/solver-rewards/llms.txt

Use this file to discover all available pages before exploring further.

The pipeline batches all solver payment transfers into Gnosis Safe multisend transactions so that the entire week’s payouts can be authorized with a single Safe signature round. The post_multisend() function in src/multisend.py handles encoding and submission; Transfer.as_multisend_tx() in src/models/transfer.py handles per-transfer encoding.

Why multisend

A typical payout run involves tens to hundreds of individual transfers — COW tokens to each solver on mainnet, native tokens to each solver on the target chain, and overdraft state updates. Submitting each transfer as a standalone Safe transaction would require a separate round of co-signer approvals for every one. Multisend bundles them all into a single DELEGATE_CALL to the Safe’s canonical multisend contract (0x40A2aCCbd92BCA938b02010E17A5b8929b49130D), reducing the approval burden to a fixed three transactions per payout run.

Three transactions per payout run

Each run of auto_propose() in src/fetch/transfer_file.py posts exactly three multisend transactions:

COW transfers

ERC-20 COW token transfers to every eligible solver. Always posted to Ethereum mainnet regardless of the target network being processed.

Native transfers

Native gas token transfers to eligible solvers on the target network (ETH, xDAI, AVAX, etc.).

Overdraft updates

Contract calls to the overdraft management contract (0x8Fd67Ea651329fD142D7Cfd8e90406F133F26E8a) on the target network, recording any solvers that received less than their owed amount.

Transaction posting flow

1

Encode individual transfers

Each Transfer object calls as_multisend_tx() to produce a MultiSendTx. Native transfers send ETH directly to the recipient; ERC-20 transfers call the token contract’s transfer(address,uint256) function:
src/models/transfer.py
def as_multisend_tx(self) -> MultiSendTx:
    """Converts Transfer into encoded MultiSendTx bytes"""
    receiver = Web3.to_checksum_address(self.recipient.address)
    if self.token_type == TokenType.NATIVE:
        return MultiSendTx(
            operation=MultiSendOperation.CALL,
            to=receiver,
            value=self.amount_wei,
            data=HexStr("0x"),
        )
    if self.token_type == TokenType.ERC20:
        assert self.token is not None
        return MultiSendTx(
            operation=MultiSendOperation.CALL,
            to=Web3.to_checksum_address(str(self.token.address)),
            value=0,
            data=ERC20_CONTRACT.encode_abi(
                abi_element_identifier="transfer",
                args=[receiver, self.amount_wei],
            ),
        )
2

Prepend WETH unwrap if necessary

Before encoding the COW and native transfer batches, prepend_unwrap_if_necessary() checks whether the Safe’s native ETH balance covers the total outgoing value. If not, it prepends a WETH withdraw() call to the transaction list to unwrap enough WETH first.
src/multisend.py
def prepend_unwrap_if_necessary(
    client: EthereumClient,
    safe_address: ChecksumAddress,
    transactions: list[MultiSendTx],
    wrapped_native_token: ChecksumAddress,
    skip_validation: bool = False,
) -> list[MultiSendTx]:
    eth_balance = client.get_balance(web3.to_checksum_address(safe_address))
    eth_needed = sum(t.value for t in transactions)
    if eth_balance < eth_needed:
        weth = weth9(client.w3, wrapped_native_token)
        weth_balance = weth.functions.balanceOf(safe_address).call()
        weth_unwrap_amount = eth_needed - eth_balance

        if weth_balance + eth_balance < eth_needed:
            message = (
                f"{safe_address} has insufficient WETH + ETH balance for transaction!"
                ...
            )
            if not skip_validation:
                raise ValueError(message)
            log.warning(f"{message} - proceeding to build transaction anyway")

        transactions.insert(
            0,
            MultiSendTx(
                operation=MultiSendOperation.CALL,
                to=weth.address,
                value=0,
                data=weth.encode_abi(
                    abi_element_identifier="withdraw", args=[weth_unwrap_amount]
                ),
            ),
        )
    return transactions
In production (auto_propose()), both the COW and native batches pass skip_validation=True. This allows the transaction to be proposed to the Safe queue even when the balance is temporarily insufficient — for example, when a previous payout’s execution is still pending.
3

Build and encode the multisend

build_encoded_multisend() passes the list of MultiSendTx objects to safe_eth’s MultiSend.build_tx_data(), which ABI-encodes them into the packed byte format that the multisend contract expects:
src/multisend.py
def build_encoded_multisend(
    transactions: list[MultiSendTx], client: EthereumClient
) -> bytes:
    multisend = MultiSend(ethereum_client=client)
    log.info(f"Packing {len(transactions)} transfers into MultiSend")
    tx_bytes: bytes = multisend.build_tx_data(transactions)
    return tx_bytes
4

Build the Safe transaction

post_multisend() wraps the encoded multisend bytes in a Safe multisig transaction targeting the multisend contract via DELEGATE_CALL. The Safe’s current on-chain nonce is fetched and the nonce_modifier offset is added:
src/multisend.py
safe_tx = safe.build_multisig_tx(
    to=multisend_contract,
    value=0,
    data=encoded_multisend,
    operation=MultiSendOperation.DELEGATE_CALL.value,
    safe_nonce=safe.retrieve_nonce() + nonce_modifier,
)
5

Sign and post to the Safe Transaction Service

The transaction is signed with the proposer’s private key (from PROPOSER_PK) and submitted to the Safe Transaction Service via TransactionServiceApi.post_transaction(). A 2-second sleep follows each post to respect the Safe API’s rate limits.
src/multisend.py
safe_tx.sign(signing_key)
tx_service = TransactionServiceApi(network, client, api_key=api_key)
print(
    f"Posting transaction with hash"
    f" {safe_tx.safe_tx_hash.hex()} to {safe.address}"
)
tx_service.post_transaction(safe_tx=safe_tx)
time.sleep(2)  # attempt to avoid Safe API's rate limits
return int(safe_tx.safe_nonce)
6

Report nonces via Slack

After all three transactions are posted, auto_propose() sends a Slack message containing the nonces and Safe queue URLs for both the COW and native Safes so that co-signers can review and approve:
src/fetch/transfer_file.py
post_to_slack(
    slack_client,
    channel=slack_channel,
    message=(
        f"""Solver Rewards transactions for network {config.dune_config.dune_blockchain}
        pending signatures:\n
        COW transfers on mainnet with nonce {nonce_cow},
        see {config.payment_config.safe_queue_url_cow}.\n
        Native transfers on {config.dune_config.dune_blockchain} with nonce {nonce_native},
        see {config.payment_config.safe_queue_url_native}.\n
        Overdrafts on {config.dune_config.dune_blockchain} with nonce {nonce_overdrafts},
        see {config.payment_config.safe_queue_url_native}."""
    ),
    sub_messages=log_saver_obj.get_values(),
)

Nonce modifier system

When the pipeline runs across multiple networks in the same week, it proposes all COW-transfer transactions against the same Ethereum mainnet Safe. To allow all of them to sit in the Safe’s queue simultaneously — each with a unique nonce — the nonce_modifier shifts the Safe’s current nonce by a fixed, per-network offset. The offsets are derived at startup from the Network enum order:
src/config.py
nonce_modifier_dict = {
    network: idx for idx, network in enumerate(reversed(list(Network)), start=0)
}
This assigns INK an offset of 0, and increments toward MAINNET which gets the highest offset. For example, with 10 networks (len(Network) == 10):
NetworkCOW tx nonce offsetNative tx nonce offset
MAINNET910 (= len(Network))
BASE80
ARBITRUM_ONE70
BNB60
AVALANCHE50
POLYGON40
GNOSIS30
LINEA20
PLASMA10
INK00
For mainnet, the native transfer uses offset len(Network) (10) and the overdraft update uses len(Network) + 1 (11) to avoid colliding with the COW transfer at offset 9.
The NONCE_MODIFIER environment variable overrides the computed offset entirely. Only set this manually if you are re-running a failed proposal or recovering from a nonce gap — incorrect values will cause Safe transaction ordering conflicts.

Required environment variables

VariableDescription
PROPOSER_PKPrivate key of the Safe proposer account used to sign multisend transactions.
PAYOUTS_SAFE_ADDRESS_MAINNETChecksum address of the Ethereum mainnet Safe used for COW token transfers.
PAYOUTS_SAFE_ADDRESSChecksum address of the target-network Safe used for native token and overdraft transfers.
SAFE_API_KEYOptional API key for the Safe Transaction Service, read via os.getenv("SAFE_API_KEY").
NODE_URL_MAINNETRPC endpoint for Ethereum mainnet (used for COW transfer Safe).
NODE_URLRPC endpoint for the target network (used for native transfer Safe).
NONCE_MODIFIEROptional override for the per-network nonce offset. Defaults to the computed value from nonce_modifier_dict.

Monitoring pending transactions

After posting, the Safe queue URLs are constructed in PaymentConfig using the network’s short name:
src/config.py
safe_queue_url_cow = (
    f"https://app.safe.global/transactions/queue?safe=eth:"
    f"{payment_safe_address_cow}"
)

safe_queue_url_native = (
    f"https://app.safe.global/transactions/queue?safe="
    f"{short_name}:{payment_safe_address_native}"
)
These URLs are included in the Slack notification so co-signers can navigate directly to the pending transactions in the Safe web app.