PoC cross-chain payment infrastructure prepared for Oak Network, using Chainlink CCIP and LayerZero v2 + Stargate v2.
Disclaimer: If you intend to adopt or replicate this code in production, you should conduct your own due diligence, including security audits, testing, and compliance review.
- Source chain: An authorized agent calls
IntentSenderto initiate a payment intent and bridge funds. - Destination chain: A bridge-specific adapter (
ChainlinkCCIPAdapterorLayerZeroStargateAdapter) validates provenance and forwards funds toCrossChainExecutor. - CrossChainExecutor: Enforces idempotency via
intentId, validates adapter integrity, approves the treasury, and dispatches the intent payload (e.g.processPayment). -- SimpleFundReceiver: Minimal treasury that receives payments and can request refunds viacreateRefundIntent. -- Refunds: Treasury callscreateRefundIntent(intentId, amount, recipient). An off-chain agent then callssendRefundCCIPorsendRefundLZStargateto bridge funds back to the source chain.
- Single destination chain: The
IntentSenderis configured for one destination (one CCIP adapter, one LayerZero adapter). Multi-destination support would require additional configuration. - SimpleFundReceiver: Minimal treasury that tracks payments and supports refunds. Production deployments would typically use a full treasury contract.
- CCIP chain selector: Chainlink’s identifier for a chain (not EVM
chainId).
- EID (endpoint ID): LayerZero’s chain identifier (not EVM
chainId). - Peer: Trusted remote sender identity for a given source EID. In this PoC, the peer is the source-chain
IntentSender(encoded asbytes32). - Stargate: Token-bridging app on LayerZero v2; implements
IOFTsend/quote for bridged assets.
- Agent →
IntentSender.sendIntentCCIP(intent, payload, gasLimit) IntentSender._sanitizeIntent(...):- Sets
intent.account = msg.sender - Sets
intent.sourceChainId = block.chainid - Validates amount, receiver, deadline
- Sets
IntentSender:- Pulls tokens from the payer
- Approves the CCIP router
- Calls
CCIP_ROUTER.ccipSend(...)with:data = abi.encode(intent, payload)tokenAmounts= bridged token and amount
- CCIP router →
ChainlinkCCIPAdapter._ccipReceive(message) - Adapter decodes
(intent, payload)frommessage.dataand verifies:executor.getIntentSender(intent.sourceChainId) == abi.decode(message.sender, (address))executor.getCcipChainSelector(intent.sourceChainId) == message.sourceChainSelector- Token and amount in the message match the intent
- Adapter transfers received tokens to the executor and calls:
Executor.executeIntent(keccak256("CCIP"), intent, payload)
Executor.executeIntent(...)verifies:- Intent not already processed
- Caller is the registered CCIP adapter
- Intent status (or marks as Failed on validation error)
- Executor approves the treasury and calls
intent.treasury.call(payload)(e.g.processPayment). - Treasury pulls funds from the executor via
transferFrom.
- Agent →
IntentSender.sendIntentLZStargate(params, intent, payload) IntentSender._sanitizeIntent(...):- Sets
intent.accountandintent.sourceChainId - Validates amount, receiver, deadline
- Sets
IntentSender:- Pulls tokens from the payer
- Calls Stargate
send(...)withSendParam:dstEid= destination LayerZero EIDto= destination adapter address (asbytes32)composeMsg=abi.encode(intent, payload)oftCmd= taxi/compose mode
- Pays
MessagingFee.nativeFee
- Stargate delivers tokens to the destination adapter and triggers the compose callback.
- LayerZero EndpointV2 →
LayerZeroStargateAdapter.lzCompose(from, guid, message, ...) - Adapter decodes
(intent, payload)and verifies:- Caller is the LayerZero endpoint
composeFrom == bytes32(uint256(uint160(executor.getIntentSender(intent.sourceChainId))))executor.getLayerZeroEid(intent.sourceChainId) == srcEidfromis the configured Stargate for the delivered token
- Adapter transfers tokens to the executor and calls:
Executor.executeIntent(keccak256("LAYERZERO"), intent, payload)
- Execution matches the CCIP path (same executor and treasury flow).
- Owner calls
SimpleFundReceiver.initiateRefund(intentId) SimpleFundReceiver:- Transfers tokens to the executor
- Calls
Executor.createRefundIntent(intentId, amount, recipient)
- Executor stores the refund request (including
sourceChainIdfrom the original intent).
- Agent calls
Executor.sendRefundCCIP(intentId)orExecutor.sendRefundLZStargate(intentId, stargate)- The refund bridge can differ from the inbound bridge.
- Executor:
- Fetches the adapter for the chosen bridge
- Quotes fee via
adapter.quoteRefundFee(...) - Approves the adapter and calls
adapter.sendRefund(...)
- CCIP: Adapter calls the CCIP router to bridge tokens to the recipient on the source chain.
- LayerZero/Stargate: Adapter calls Stargate
send(...)to bridge tokens back to the recipient.
forge build-
Choose a source chain (where the user pays) and a destination chain (where funds are received).
- CCIP: both chains must be supported by CCIP.
- LayerZero/Stargate: both must be supported by LayerZero v2 and Stargate v2.
-
Obtain native gas tokens on both chains.
-
Obtain a CCIP-transferable ERC20 on the source chain and its destination-chain counterpart.
- For CCIP test tokens, see the Chainlink test token docs.
-
Look up CCIP router addresses and chain selectors: CCIP directory
-
Set environment variables:
SOURCE_RPC_URL,DEST_RPC_URLPRIVATE_KEYAGENT(authorized address for sending intents and executing refunds)
export CCIP_ROUTER=<dest_chain_ccip_router>
export AGENT=<agent_address>
# Optional: configure executor during deployment
export SOURCE_CHAIN_ID=<source_chain_evm_chain_id>
export SOURCE_SENDER=<intent_sender_address>
export CCIP_SOURCE_CHAIN_SELECTOR=<source_chain_ccip_selector>
export LZ_SOURCE_EID=<source_chain_layerzero_eid>
export LZ_ENDPOINT_V2=<dest_chain_layerzero_endpoint>
export LZ_DESTINATION_TOKEN=<dest_token_delivered_by_stargate>
export LZ_DESTINATION_STARGATE=<dest_stargate_pool_for_token>
forge script script/DeployDestination.s.sol \
--rpc-url $DEST_RPC_URL \
--broadcast \
--private-key $PRIVATE_KEYSave: CrossChainExecutor, ChainlinkCCIPAdapter, LayerZeroStargateAdapter (if used), SimpleFundReceiver.
export AGENT=<agent_address>
export CCIP_ROUTER=<source_chain_ccip_router>
export CCIP_DEST_CHAIN_SELECTOR=<dest_chain_ccip_selector>
export CCIP_DEST_ADAPTER=<ccip_adapter_from_step_1>
export LZ_DEST_EID=<dest_chain_layerzero_eid>
export LZ_DEST_ADAPTER=<layerzero_adapter_from_step_1>
forge script script/DeploySource.s.sol \
--rpc-url $SOURCE_RPC_URL \
--broadcast \
--private-key $PRIVATE_KEYSave: IntentSender.
If the source-chain IntentSender was deployed after the destination, configure the executor:
export EXECUTOR=<executor_address>
export SOURCE_CHAIN_ID=<source_chain_evm_chain_id>
export SOURCE_SENDER=<intent_sender_from_step_2>
export CCIP_SOURCE_CHAIN_SELECTOR=<source_chain_ccip_selector>
export LZ_SOURCE_EID=<source_chain_layerzero_eid>
forge script script/ConfigureExecutor.s.sol \
--rpc-url $DEST_RPC_URL \
--broadcast \
--private-key $PRIVATE_KEYexport SENDER=<intent_sender_address>
export DEST_ADAPTER=<ccip_or_lz_adapter_address>
export AMOUNT=<amount_in_token_decimals>
# CCIP
export BRIDGE=CCIP
export DEST_CHAIN_SELECTOR=<dest_chain_ccip_selector>
# Plus: SOURCE_TOKEN, intent params, etc.
# LayerZero/Stargate
export BRIDGE=LAYERZERO
export STARGATE=<stargate_pool_address>
export DST_EID=<dest_chain_layerzero_eid>
# Plus: MIN_AMOUNT_LD, intent params, etc.
forge script script/SendIntent.s.sol \
--rpc-url $SOURCE_RPC_URL \
--broadcast \
--private-key $PRIVATE_KEY- Track CCIP messages at ccip.chain.link
- Verify payment on destination:
cast call $DEST_RECEIVER "getPayment(bytes32)" $INTENT_ID --rpc-url $DEST_RPC_URL
SOURCE_CHAIN_IDis the EVMchainId;DEST_CHAIN_SELECTORis the CCIP chain selector.- LayerZero uses
EID(endpoint ID) for chain identification. intent.treasuryis the destination-chain contract that receives the payment (e.g.SimpleFundReceiver).payloadis the ABI-encoded calldata for the treasury function (e.g.processPayment(intentId, sender, token, amount, "")).
Example testnet transactions:
- Ethereum Sepolia → Base Sepolia (Payment): 0xcd5bb85acc91ca6bf76cdce60a2a79974a4037a4ec2f5949a9e58d2c21a6b2a9
- Base Sepolia → Ethereum Sepolia (Refund): 0xd6349d3020871e8b6ae64be262732788fd0aeb5a8d2237d830783b58502ad4d1