Skip to main content

Outbound from Push Chain

A minimal Push Chain contract that demonstrates an outbound cross-chain call. The contract calls UniversalGatewayPC (UGPC), the gateway contract responsible for handling outbound calls, and runs increment on a BNB Testnet counter via the contract's CEA.

This is the smallest contract-initiated outbound that runs end to end on Donut.

Note: For the conceptual background (UGPC, CEAs, fee model), see Contract-Initiated Multichain Execution.

What this example shows

AspectDetails
DirectionPush Chain to BNB Testnet. One-way. No back-leg.
TriggerA regular EOA calls dispatchOutbound(...) on the Push contract.
Identity on BNBThe destination contract sees msg.sender equal to the Push contract's deterministic CEA on BNB.
Funds movementNone. The example dispatches a payload only. The same surface supports bridging PRC20 (token + amount); see the Advanced Patterns for funds variants.
Verified onDonut Testnet.

Identity model

When a Push Chain contract dispatches through UGPC, the TSS validators relay the call to the destination chain and execute it from the contract's CEA on that chain.

The CEA is always deterministic, derived from the contract's Push Chain address. From the destination contract's perspective the CEA is a normal address, and there is no Push-specific code at the destination.



The Push contract's CEA on the destination chain is computable off-chain via deriveExecutorAccount, so destination protocols can whitelist or pre-fund the CEA before the first cross-chain activity has happened.

Solidity Code

Minimal Push-Side Outbound Dispatcher
MinimalContractInitiatedExecutor.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

/// @notice Outbound request shape consumed by UGPC.
struct UniversalOutboundTxRequest {
bytes recipient; // CEA or target address on the external chain (bytes-encoded)
address token; // PRC20 on Push Chain to bridge (address(0) for none)
uint256 amount; // Amount of PRC20 to bridge
uint256 gasLimit; // Gas limit for external execution
bytes payload; // ABI-encoded calldata for the CEA to execute
address revertRecipient;// Address to receive bridged funds if external tx reverts
}

interface IUniversalGatewayPC {
function sendUniversalTxOutbound(UniversalOutboundTxRequest calldata req) external payable;
}

interface IPRC20 {
function approve(address spender, uint256 amount) external returns (bool);
}

contract MinimalContractInitiatedExecutor {
/// @notice UGPC predeploy on Push Chain Donut Testnet.
address public immutable ugpc;

event OutboundDispatched(
bytes indexed recipient,
address indexed token,
uint256 amount,
bytes payload,
address revertRecipient
);

error ZeroAddress();

constructor(address _ugpc) {
if (_ugpc == address(0)) revert ZeroAddress();
ugpc = _ugpc;
}

/// @notice Dispatch an outbound cross-chain execution from this contract.
/// @dev `msg.value` must cover the UGPC protocol fee. If bridging PRC20
/// tokens, this function approves UGPC for the amount before calling.
function dispatchOutbound(
address token,
uint256 amount,
bytes calldata recipient,
uint256 gasLimit,
bytes calldata payload,
address revertRecipient
) external payable {
if (revertRecipient == address(0)) revert ZeroAddress();

if (amount > 0) {
if (token == address(0)) revert ZeroAddress();
IPRC20(token).approve(ugpc, amount);
}

IUniversalGatewayPC(ugpc).sendUniversalTxOutbound{value: msg.value}(
UniversalOutboundTxRequest({
recipient: recipient,
token: token,
amount: amount,
gasLimit: gasLimit,
payload: payload,
revertRecipient: revertRecipient
})
);

emit OutboundDispatched(recipient, token, amount, payload, revertRecipient);
}

receive() external payable {}
}

The contract is intentionally tiny. It owns no business logic. It exists to forward an outbound request through UGPC.

Real production contracts wrap dispatchOutbound behind their own access control, request-ID tracking, and event correlation; layer those on top.

Source Code

Run

The runner deploys the contract on first run, derives the BNB CEA address, encodes increment() for the BNB counter, dispatches with PC value covering the UGPC protocol fee, and prints both Push and BNB explorer URLs.

git clone https://github.com/pushchain/push-chain-examples.git
cd push-chain-examples/core-sdk-functions/contract-initiated-outbound-execution

forge build
npm install
cp .env.sample .env
# Edit .env: set PUSH_PRIVATE_KEY (Push native wallet with at least 10 PC).
npm start

Prerequisites

  • Foundry and Node.js v18+.
  • A Push native wallet on Donut Testnet with at least 10 PC (deploy + dispatch + headroom). To get funds, visit the Push faucet.
  • No funding needed on the BNB CEA. This is a one-way example; the destination tx's gas comes from the UGPC fee paid in PC and converted internally.

What can go wrong

SymptomCauseFix
dispatchOutbound reverts immediately on Pushmsg.value is zero or below the UGPC protocol feePass enough PC as msg.value. The runner uses 5 PC by default.
Push tx succeeds but no BNB tx firesgasLimit was 0 or under the auto-floor (~500k) and the destination tx ran out of gasPass gasLimit: 2_000_000 on the outbound. UGPC charges only for actual gas used and refunds the surplus, so over-provisioning is essentially free. See Operational Knobs.
BNB tx fires but the destination contract revertsThe destination contract restricts callers (whitelist or EOA-only guard) and does not recognise the CEAWhitelist the deterministic CEA address on the destination contract. Derive it off-chain via PushChain.utils.account.deriveExecutorAccount.