Sync parameters across chains
When a custom contract holds parameters that need to change over time, those updates often need to land on every chain where the contract is deployed. The contract update pattern lets you make these changes from the Hub and have them propagate to each spoke chain natively, so you don't manage a separate update flow per chain, and don't need to hold secure cold wallets on each chain. Common cases include updating a Merkle root, changing fee parameters on a custom manager, or rotating an address or toggling a configuration flag.
Implement ITrustedContractUpdate
Your contract implements ITrustedContractUpdate. The protocol calls trustedCall on your contract when a Hub manager triggers an update. The call carries the pool and share class it applies to, plus an opaque payload that your contract decodes and acts on.
interface ITrustedContractUpdate {
/// @notice Triggers an update on the target contract.
/// @dev Sent from the trusted hub manager role.
function trustedCall(PoolId poolId, ShareClassId scId, bytes calldata payload) external;
}
A minimal implementation guards the caller, decodes the payload into the parameter you want to change, and applies it. The only authorized caller is the protocol's contract updater, whose address your contract stores at deployment. This mirrors the protocol's own OnchainPM, which updates a strategist's policy root via the same pattern.
contract MyManager is ITrustedContractUpdate {
address public immutable contractUpdater; // the protocol's update entrypoint
PoolId public immutable poolId;
mapping(bytes32 strategist => bytes32 root) public policy;
constructor(address contractUpdater_, PoolId poolId_) {
contractUpdater = contractUpdater_;
poolId = poolId_;
}
function trustedCall(PoolId poolId_, ShareClassId, bytes calldata payload) external {
require(poolId == poolId_, InvalidPoolId());
require(msg.sender == contractUpdater, NotAuthorized());
(bytes32 strategist, bytes32 newRoot) = abi.decode(payload, (bytes32, bytes32));
policy[strategist] = newRoot;
emit UpdatePolicy(strategist, newRoot);
}
}
The payload is whatever your contract and the Hub caller agree on, here a (strategist, root) pair. Encode an update kind into it if your contract supports more than one kind of update.
ITrustedContractUpdate.trustedCall is reserved for updates originating from the trusted hub manager role on the Hub. The sibling IUntrustedContractUpdate.untrustedCall handles updates that anyone can submit on the spoke side and additionally passes centrifugeId and sender, which your contract MUST validate.
Trigger updates from the Hub
A Hub manager triggers the update by calling updateContract on the Hub. target is your contract address on the destination chain (as bytes32), and payload is the data your trustedCall decodes.
function updateContract(
PoolId poolId,
ShareClassId scId,
uint16 centrifugeId, // chain where the target contract lives
bytes32 target, // your contract on that chain
bytes calldata payload, // decoded by trustedCall
uint128 extraGasLimit, // extra gas for the remote call
address refund // receives excess gas
) external payable;
Set extraGasLimit to cover the gas your trustedCall needs to execute on the destination chain; refund receives any unused gas.
A message originating from the Hub goes to only one Spoke. To update the same contract on several chains at once, batch the calls with multicall (the Hub inherits IBatchedMulticall):
hub.multicall([
abi.encodeCall(hub.updateContract, (poolId, scId, chainA, target, payload, extraGas, refund)),
abi.encodeCall(hub.updateContract, (poolId, scId, chainB, target, payload, extraGas, refund))
// ...
]);
Each entry is routed to its centrifugeId and delivered to that chain's trustedCall. The scId lets one call target a specific share class, so the same payload shape can update per-share-class parameters.