
Overview
A callback contract lives on a destination chain and receives calls triggered by a reactive contract. When a reactive contract requests a callback, Reactive Network submits a transaction on the destination chain through a callback proxy, and that proxy calls your contract. From your contract's side, nothing exotic happens: it's an ordinary function call from an ordinary address. There's no react(), no subscription, and no LogRecord. What a callback contract needs is a way to prove the call is authentic, and funds to pay for it.
Inheritance
AbstractCallback is the base, and IPayable is needed only as the type of the constructor's proxy argument.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.29;
import { IPayable } from "@reactive/src/interfaces/IPayable.sol";
import { AbstractCallback } from "@reactive/src/base/AbstractCallback.sol";
contract MyCallback is AbstractCallback {
event Handled(address indexed reactive_, uint256 value_);
// `payable` so the contract can be funded at deployment.
constructor(IPayable callbackProxy_, address callbackSender_)
payable
AbstractCallback(callbackProxy_, callbackSender_)
{
}
function myCallback(address reactive_, uint256 value_)
external
onlyServiceProvider
onlyCallbackSender(reactive_)
{
emit Handled(reactive_, value_);
}
}
Three requirements are doing the work:
-
Inherit AbstractCallback with two constructor arguments.
callbackProxy_is the callback proxy on the chosen chain, which becomes the contract's service provider for payment.callbackSender_is the reactive contract you're authorizing. Both are stored immutably, so neither can be changed after deployment. -
Declare the first parameter as an
addressand guard the method twice. The proxy overwrites the first 160 bits of the payload with the address of the reactive contract that requested the callback, so the first argument is always that address regardless of what you name it. -
Keep the contract funded. The proxy charges the recipient for callback execution, in this chain's native token. A
payableconstructor lets you fund at deployment, but it isn't required:receive()is inherited as payable, anddepositTo()on the proxy credits the contract without its cooperation.
A contract can't be both reactive and callback. AbstractPayer holds a single immutable _SERVICE_PROVIDER, and AbstractReactive hardcodes it to SYSTEM while AbstractCallback sets it to the proxy, so inheriting both gives the same base constructor arguments twice and won't compile. A reactive contract that wants to call itself has to check msg.sender and the injected address by hand.
| Name | Comes from | What it's for |
|---|---|---|
_CALLBACK_SENDER | AbstractCallback | The reactive contract authorized to trigger your callbacks |
onlyCallbackSender | AbstractCallback | Checks the injected address against _CALLBACK_SENDER, reverting with CallbackNotAuthorized |
_SERVICE_PROVIDER | AbstractPayer | The callback proxy, which is what bills you |
onlyServiceProvider | AbstractPayer | Rejects calls from anyone but the proxy, reverting with NotAuthorized |
pay(uint256) | AbstractPayer | Already implemented, and it verifies the caller. Don't write your own |
_coverDebt() | AbstractPayer | Settles outstanding debt from the contract's balance. Internal, so expose it yourself if you want it callable |
receive() | AbstractPayer | Accepts funds, virtual so you can override it |
Authorizing Callbacks
The two modifiers answer two different questions, and one is not a substitute for the other.
-
onlyServiceProviderasks whether the caller is the callback proxy. Without it, anyone can callmyCallback()directly and pass whatever address they like as the first argument, defeating the second check entirely. -
onlyCallbackSender(reactive_)asks whether the injected address is the reactive contract you authorized. Without it, any reactive contract on the network can drive your callback through the legitimate proxy.
Together they establish that the call came through the network and originated from the contract you trust. Use both, always.
-
The first argument's name is yours; only its type and position matter. Pass it straight to
onlyCallbackSenderand use it as the sender's identity if you need it. -
Inheriting AbstractCallback isn't strictly required. Any contract with a matching function signature will receive the call, but then it has no authentication and no way to pay, so it will accept forged calls and accrue debt it can't settle.
-
The proxy checks that the target address has code before calling, reverting with
NotAContractotherwise. A callback aimed at an address holding no contract never reaches the call stage, and isn't charged.
Paying For Callbacks
Callbacks are billed to the recipient, not to the reactive contract that requested them. The request happens on Reactive Network, but the charge lands here, in this chain's native token: ETH on Ethereum, BNB on BNB Chain, and so on, never REACT.
A contract in debt receives nothing. The proxy reverts with InDebt before it attempts delivery, so callbacks stop arriving until the debt clears. Charges draw on reserves first, and AbstractPayer settles any shortfall automatically through pay(), so this only bites a contract that has run out of both. Economy covers funding routes, the fee formula, and the cast commands for reading balances, reserves, and debt.
Two things about callback billing that have no equivalent on the reactive side:
-
A failed callback still costs you. The proxy charges after the call regardless of the outcome, so a reverting callback method burns gas, emits
CallbackFailure, and bills you for the attempt. -
Not all the requested gas reaches you. The proxy withholds gas for its own accounting and for settling payment if you owe anything, and forwards only the remainder. A
gasLimittoo low to cover that overhead fails withInsufficientGasbefore your method runs; one that clears the overhead but not your method's needs lets your method revert and surfaces asCallbackFailure. Both are set on the reactive side, so a recipient that keeps failing may need the requester to raise the limit rather than more funding. -
_coverDebt()is internal, so expose it yourself if an outside call should be able to settle:
/// @notice Settles outstanding debt from this contract's own balance.
function coverDebt() external {
_coverDebt();
}
Deployment Order
Your callback contract needs the reactive contract's address at construction, and the reactive contract needs yours before it can name a recipient. Since _CALLBACK_SENDER is immutable, the cycle has to break on the reactive side:
- Deploy the reactive contract.
- Deploy the callback contract, passing the callback proxy address and the reactive contract's address.
- Set the recipient on the reactive contract through an owner-only setter.
- Fund both. The reactive contract pays for reactive transactions in REACT; the callback contract pays for callbacks in the destination chain's native currency.
Getting callbackSender_ wrong is unrecoverable. There's no setter, so a mismatched address means redeploying.