diff --git a/starter-templates/README.md b/starter-templates/README.md index 172107f1..ec49b993 100644 --- a/starter-templates/README.md +++ b/starter-templates/README.md @@ -64,6 +64,9 @@ They are more comprehensive than **building-blocks**, and can be adapted into yo 9. **Verifiable Build** — [`./verifiable-build`](./verifiable-build) Reproducible Docker-based builds for TypeScript workflows, enabling third-party verification that deployed workflows match their source code. +10. **Cross Chain Token Aggregator** — [`./cross-chain-token-aggregator`](./cross-chain-token-aggregator) + Let users aggregate their tokens received on scattered chains to a single chain by monitoring the Transfer event of ERC-20 compatible token, powered by event-driven CRE workflows, Across and Chainlink CCIP Bridge. + > Each subdirectory includes its own README with template-specific steps and example logs. ## License diff --git a/starter-templates/cross-chain-token-aggregator/README.md b/starter-templates/cross-chain-token-aggregator/README.md new file mode 100644 index 00000000..87719e9a --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/README.md @@ -0,0 +1,9 @@ +# Cross Chain Token Aggregator + +Monitor on-chain events for token transfer, and routes the token to the user desired address via the configured bridge (chainlink ccip or across) + +## Available Languages + +| Language | Directory | +|----------|-----------| +| TypeScript | [cross-chain-token-aggregator-ts](./cross-chain-token-aggregator-ts) | diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.cre/template.yaml b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.cre/template.yaml new file mode 100644 index 00000000..217d411f --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.cre/template.yaml @@ -0,0 +1,26 @@ +kind: starter-template +id: cross-chain-token-aggregator-ts +projectDir: . +title: "Cross Chain Token Aggregator (TypeScript)" +description: "Monitor on-chain events for token transfer, and routes the token to the user desired address via the configured bridge (chainlink ccip or across)" +language: typescript +category: workflow +capabilities: + - log-trigger + - chain-read + - chain-write + - http + - ccip +tags: + - cross-chain + - bridging + - event-driven + - ccip + - defi +networks: + - ethereum-testnet-sepolia + - ethereum-testnet-sepolia-base-1 +workflows: + - dir: my-workflow +postInit: | + Deploy the Uniflow contract and update config for runtime and secrets accordingly. Refer to README.md for details. diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.gitignore b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.gitignore new file mode 100644 index 00000000..627f79a3 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/.gitignore @@ -0,0 +1,5 @@ +*.env +node_modules +.cre_build_tmp.js +tmp.js +tmp.wasm \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/README.md b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/README.md new file mode 100644 index 00000000..3c9932af --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/README.md @@ -0,0 +1,267 @@ +# Cross-Chain Token Aggregator — CRE Starter Template (TypeScript) + +Let users aggregate their tokens received on scattered chains to a single chain by utilizing the Transfer event of ERC-20 compatible token, powered by event-driven CRE workflows. + +**⚠️ DISCLAIMER** + +This template is an educational example to demonstrate how to interact with Chainlink systems, products, and services. It is provided **"AS IS"** and **"AS AVAILABLE"** without warranties of any kind, has **not** been audited, and may omit checks or error handling for clarity. **Do not use this code in production** without performing your own audits and applying best practices. + +--- + +## Overview + +This template demonstrates the **detect → notify → bridge** pattern using Chainlink CRE (Compute Runtime Environment). It is designed around a **user-centric aggregation flow**: a normal user can send ERC-20 tokens from any wallet to a single configured address, and the CRE workflow automatically detects the inbound transfer, notifies them via Telegram, and bridges the tokens to their wallet on a destination chain — without any manual intervention. + +### Use Cases + +- **Personal cross-chain aggregator**: User receives token scattered on different chain, which gets accumulated to a single chain. +- **Bridge abstraction layer**: Abstract away the choice of bridge (Across vs CCIP) behind a per-token config — users never interact with the bridge directly +- **Multi-source token consolidation**: Tokens arriving from multiple senders/contracts all funnel to one destination address on Unichain +- **On-chain event alerting + action**: Combine real-time Telegram notifications with automated on-chain bridging for any ERC-20 transfer + +## Architecture + +``` + User receives tokens to their address (any source) + │ + │ Transfer(from, to=userAddress, amount) + v +┌─────────────────────────────────────────────────────────────────────┐ +│ CRE DON │ +│ │ +│ ┌──────────────┐ ┌────────────────┐ ┌──────────────────────┐ │ +│ │ LogTrigger │──>│ Decode Transfer│──>│ Read ERC-20 │ │ +│ │ (Transfer │ │ Event (viem) │ │ Decimals, Allwances │ │ +│ │ to user) │ └────────────────┘ └──────────┬───────────┘ │ +│ └──────────────┘ │ │ +│ v │ +│ ┌────────────────────────┐ │ +│ │ Telegram Notification │ │ +│ │ "Token received from │ │ +│ │ 0x... amount X" │ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ┌────────────v───────────┐ │ +│ │ BridgeFactory │ │ +│ │ decides token bridge │ │ +│ │ → "across" | "ccip" │ │ +│ └────────────┬───────────┘ │ +│ │ │ +│ ┌──────────────────────────────────────┤ │ +│ │ │ │ +│ ┌──────────v──────────┐ ┌──────────────v─────────┐ │ +│ │ Across Bridge │ │ Chainlink CCIP Bridge │ │ +│ │ GET /swap/approval │ │ Encode CCIP params │ │ +│ │ Encode + sign │ │ Sign + writeReport │ │ +│ │ writeReport(0x01…) │ │ writeReport(0x02…) │ │ +│ └──────────┬──────────┘ └──────────────┬─────────┘ │ +└──────────────┼──────────────────────────────────────┼───────────────┘ + │ │ + └─────────────────┬────────────────────┘ + │ + ┌──────────v──────────┐ + │ KeystoneForwarder │ + │ → Uniflow.onReport │ + └──────────┬──────────┘ + │ + ┌──────────v──────────┐ + │ Uniflow.sol │ + │ 0x01 → Across dep. │ + │ 0x02 → CCIP send │ + └──────────┬──────────┘ + │ + ┌──────────v──────────┐ + │ Tokens arrive in │ + │ user's wallet on │ + │ base chain │ + └─────────────────────┘ +``` + +## Components + +### CRE Workflow (`my-workflow/`) + +The TypeScript workflow runs off-chain inside the CRE DON: + +1. **LogTrigger** fires when any configured ERC-20 token emits a `Transfer(from, to, amount)` event where `to` matches the configured `targetUserAddress`, at `CONFIDENCE_LEVEL_FINALIZED` +2. **Decodes** the transfer event using `viem`'s `decodeEventLog` +3. **Reads** the token's `decimals()` on-chain via `evmClient.callContract` +4. **Sends a Telegram message** notifying the user of the inbound transfer (sender, amount, token contract) +5. **Looks up** the token in `config.tokenMap` to determine which bridge to use +6. **Routes to bridge**: + - **Across**: Calls the Across REST API (`/api/swap/approval`) to get approval and deposit calldata, encodes bridge parameters, signs a CRE report prefixed `0x01`, and writes it on-chain + - **Chainlink CCIP**: Encodes receiver, token, amount, and destination chain selector, signs a CRE report prefixed `0x02`, and writes it on-chain +7. Returns `{ success, txHash, error }` — bridging is complete once the on-chain transaction confirms + +### Smart Contracts (`contracts/`) + +**`Uniflow.sol`** — The on-chain receiver and bridge dispatcher: + +- Extends `ReceiverTemplate` — validates the CRE Forwarder and optional workflow identity before processing any report +- `setupToken(token, tokenConfig)` — owner configures which tokens are supported with a minimum bridging amount and receiver address +- `allowlistDestinationChainForCCIP(selector, enable)` — enable/disable destination chains for CCIP bridging +- `_processReport(bytes)` — dispatches on the first-byte opcode: `0x01` → Across, `0x02` → CCIP +- `_performAcrossBridgeOp(bytes)` — pulls tokens from owner via `safeTransferFrom`, approves the Across deposit contract, and calls `depositContract.call(depositData)` to initiate the bridge +- `_performChainlinkCCIPBridgeOp(bytes)` — pulls tokens from owner, pays CCIP fees in LINK, and calls `ccipSend` on the CCIP Router to send tokens to the destination chain + +**`ReceiverTemplate.sol`** — Abstract base with layered security: + +- Validates that `msg.sender == s_forwarderAddress` (the Chainlink KeystoneForwarder) +- Optionally validates workflow ID, workflow owner address, and workflow name +- `setForwarderAddress`, `setExpectedAuthor`, `setExpectedWorkflowName`, `setExpectedWorkflowId` — all owner-configurable post-deployment + +### Bridge Integrations (`my-workflow/bridge/`) + +New bridges can be added by implementing the `IBridge` interface and registering a key in `BridgeFactory`. + +| Bridge | Config Key | Mechanism | +|--------|-----------|-----------| +| Across Protocol | `"across"` | GET `/api/swap/approval` → encode approval + deposit calldata → report prefix `0x01` | +| Chainlink CCIP | `"chainlink_ccip"` | Encode `(receiver, token, amount, chainSelector)` → report prefix `0x02` | + +## Getting Started + +### Prerequisites + +- [Bun](https://bun.sh/) runtime installed +- [CRE CLI](https://docs.chain.link/cre) installed +- [Foundry](https://getfoundry.sh/) installed (for contract deployment) +- A Telegram bot token and chat ID (for notifications - optional) + +### 1. Install Dependencies + +```bash +cd my-workflow && bun install && cd .. +``` + +### 2. Set Up Secrets + +> **How to get Telegram credentials**: Message `@BotFather` on Telegram, create a bot with `/newbot`, and copy the token. To find your chat ID, message the bot once and then call `https://api.telegram.org/bot/getUpdates` — your chat ID is in the `message.chat.id` field. + +The `secrets.yaml` at the project root maps secret names to the workflow's environment variable names: + +```yaml +secretsNames: + TELEGRAM_BOT_ACCESS_TOKEN: + - TELEGRAM_BOT_ACCESS_TOKEN_VAR + TELEGRAM_CHAT_ID: + - TELEGRAM_CHAT_ID_VAR +``` + +### 3. Review and Customize Config + +`my-workflow/config.staging.json` controls which user address, tokens, and destination chain the workflow uses: + +```json +{ + "networks": { + "eth": { + "chainId": "11155111", + "creNetworkConfig": [{ "chainFamily": "evm", "chainSelectorName": "ethereum-testnet-sepolia", "isTestnet": true }], + "configContract": "0x4d236Ec82Af6c73835F29BBdc4b34574a0E0FdaE", + "targetUserAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "tokenArr": ["0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"], + "tokenMap": { + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": { + "unichainToken": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "bridge": "chainlink_ccip" + } + } + } + }, + "unichain": { + "chainId": "84532", + "unichainDestinationAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "chainlinkCCIPSelector": "10344971235874465080" + }, + "telegramMessageApi": "https://api.telegram.org/bot{{TELEGRAM_BOT_ACCESS_TOKEN}}/sendMessage?chat_id={{TELEGRAM_CHAT_ID}}&text={{MESSAGE}}", + "acrossApiUrl": "https://testnet.across.to" +} +``` + +Key fields to update for your own setup: + +| Field | Description | +|-------|-------------| +| `targetUserAddress` | The user's Ethereum sepolia address whose inbound transfers are monitored | +| `tokenArr` | ERC-20 token contract addresses to watch on Ethereum | +| `tokenMap[token].bridge` | `"chainlink_ccip"` or `"across"` per token | +| `tokenMap[token].unichainToken` | Corresponding token address on the destination chain | +| `unichain.unichainDestinationAddress` | The user's wallet address on base sepolia chain (where tokens gets routed) | +| `configContract` | Address of your deployed `Uniflow.sol` | + +### 4. Simulate + +LogTrigger simulation requires a real transaction hash that emitted a `Transfer` to your `targetUserAddress`. Send a small ERC-20 transfer on Sepolia to that address, then simulate: + +```bash +cre workflow simulate my-workflow --broadcast +``` +Enter the hash of the transaction containing the Transfer event, and the workflow will do its work. + +### 5. Deploy Contracts + +Deploy `Uniflow.sol` using Foundry: + +```bash +forge script script/DeployUniflow.s.sol --rpc-url --account --broadcast +``` +Now on the deployed contract: +1. Set up the token config. +2. Allowlist the destination chain selector for chainlink ccip bridging. +3. Set token approval for the desired token to the Uniflow contract, so that it can spend on your behalf. +4. Send some link to the contract to facilitate ccip fees. + +> **Sepolia contract addresses** (verify on [Chainlink docs](https://docs.chain.link/ccip/directory/testnet)): +> - CRE KeystoneForwarder: `0x15fC6ae953E024d975e77382eEeC56A9101f9F88` +> - CCIP Router: `0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59` +> - LINK Token: `0x779877A7B0D9E8603169DdbD7836e478b4624789` + +**1. Register a supported token:** +```bash +cast send \ + "setupToken(address,(address,uint256))" \ + "(,1000000)" \ + --private-key --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**2. Allowlist the Unichain destination chain for CCIP:** +```bash +cast send \ + "allowlistDestanationChainForCCIP(uint64,bool)" \ + 10344971235874465080 true \ + --private-key --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**3. Approve `Uniflow.sol` to pull the user's tokens** (required before any bridge operation executes): +```bash +cast send \ + "approve(address,uint256)" 115792089237316195423570985008687907853269984665640564039457584007913129639935 \ + --private-key --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**4. Fund the contract with LINK to cover CCIP fees:** +```bash +cast send \ + "transfer(address,uint256)" 5000000000000000000 \ + --private-key --rpc-url https://ethereum-sepolia-rpc.publicnode.com +``` + +**5. Update `configContract`** in your config JSON to the deployed `Uniflow.sol` address. + +## Customization + +- **Add a new bridge**: Implement `IBridge` in `my-workflow/bridge/integrations/` and register the key in `BridgeFactory.getBridge()` +- **Monitor tokens on multiple source chains**: Add more entries to the `networks` map in config and create additional `evmClient` + `logTrigger` instances in `main.ts` +- **Change the destination chain**: Update `unichain.chainId`, `chainlinkCCIPSelector`, and each `tokenMap[token].unichainToken` to your target chain's values +- **Per-token bridge selection**: Already supported — set `"bridge": "across"` or `"bridge": "chainlink_ccip"` individually per token in `tokenMap` +- **Richer notifications**: Extend `telegramMessageService.ts` to include bridge type, estimated arrival time, or destination tx hash in the Telegram message +- **Use Confidential HTTP**: Replace `new cre.capabilities.HTTPClient()` with the Confidential HTTP capability in `across.ts` to keep bridge quote data private from node operators + +## Security + +- The contracts are **demos** — audit and customize before production use +- `ReceiverTemplate` ensures only the CRE Forwarder can call `onReport()`. After deployment, call `setExpectedAuthor()` and `setExpectedWorkflowId()` to lock reports to your specific workflow and prevent spoofed reports +- `Uniflow.sol` pulls tokens from the `owner` via `safeTransferFrom` — the owner must `approve` the contract for at least the bridging amount before any transfer event is processed +- The CCIP bridge explicitly checks the destination chain selector against an allowlist before sending — no unexpected chains can be targeted +- Never commit `secrets.yaml` with real values or any `.env` files to version control diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitignore b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitignore new file mode 100644 index 00000000..d1a53191 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitignore @@ -0,0 +1,15 @@ +# Compiler files +cache/ +out/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ +broadcast + +# Docs +docs/ + +# Dotenv file +.env diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitmodules b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitmodules new file mode 100644 index 00000000..6d4394ed --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/.gitmodules @@ -0,0 +1,9 @@ +[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/chainlink-ccip"] + path = lib/chainlink-ccip + url = https://github.com/smartcontractkit/chainlink-ccip diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.lock b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.lock new file mode 100644 index 00000000..b25329ff --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.lock @@ -0,0 +1,14 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.15.0", + "rev": "0844d7e1fc5e60d77b68e469bff60265f236c398" + } + }, + "lib/openzeppelin-contracts": { + "tag": { + "name": "v5.5.0", + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + } + } +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.toml b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.toml new file mode 100644 index 00000000..93d3668b --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/foundry.toml @@ -0,0 +1,12 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +solc_version = "0.8.34" + +remappings = [ + "@chainlink/contracts-ccip/contracts/=lib/chainlink-ccip/chains/evm/contracts/" +] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/Uniflow.sol b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/Uniflow.sol new file mode 100644 index 00000000..fdb6da59 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/Uniflow.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.34; + +import {ReceiverTemplate} from "./interfaces/ReceiverTemplate.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IRouterClient} from "@chainlink/contracts-ccip/contracts/interfaces/IRouterClient.sol"; +import {Client} from "@chainlink/contracts-ccip/contracts/libraries/Client.sol"; + +contract Uniflow is ReceiverTemplate { + using SafeERC20 for IERC20; + + // Errors + error Uniflow__TokenNotConfigured(); + error Uniflow__MinAmountNotFulfilled(); + error Uniflow__InvalidReceiverConfigured(); + error Uniflow__AcrossBridgeDepositFailed(); + error Uniflow__EmptyReport(); + error Uniflow__CCIPChainNotAllowlisted(uint64 chainSelector); + error Uniflow__InsufficientLinkTokenForCCIPBridge(); + + // Struct + struct TokenConfig { + address receiver; + uint256 minAmountToTrigger; + } + + // Variables + mapping(address token => TokenConfig) public s_tokenConfig; + + // Mapping to keep track of allowlisted destination chains. + mapping(uint64 => bool) public s_allowlistedChains; + + IRouterClient public s_ccipRouter; + + IERC20 public s_linkToken; + + // Events + event UniflowApproval(address user, bool approval); + event AcrossBridgeInitiated(address token, address receiver, uint256 amount); + event CCIPBridgeInitiated(bytes32 messageId, address token, address receiver, uint256 amount); + + // modifiers + modifier onlyConfiguredToken(address token) { + _onlyConfiguredToken(token); + _; + } + + // constructor + constructor(address _forwarderAddress, address _ccipRouter, address _linkToken) + ReceiverTemplate(_forwarderAddress) + { + s_ccipRouter = IRouterClient(_ccipRouter); + s_linkToken = IERC20(_linkToken); + } + + function setupToken(address token, TokenConfig memory tokenConfig) external onlyOwner { + s_tokenConfig[token] = + TokenConfig({receiver: tokenConfig.receiver, minAmountToTrigger: tokenConfig.minAmountToTrigger}); + } + + function allowlistDestanationChainForCCIP(uint64 selector, bool enable) external { + s_allowlistedChains[selector] = enable; + } + + function updateReceiver(address token, address receiver) external onlyConfiguredToken(token) onlyOwner { + if (s_tokenConfig[token].receiver == address(0)) { + revert Uniflow__TokenNotConfigured(); + } + s_tokenConfig[token].receiver = receiver; + } + + function _performAcrossBridgeOp(bytes calldata report) internal { + ( + address receiver, + uint256 amount, + address token, + address approvalContract, + address depositContract, + bytes memory depositData + ) = abi.decode(report, (address, uint256, address, address, address, bytes)); + + _onlyConfiguredToken(token); + + TokenConfig memory tokenConfig = s_tokenConfig[token]; + + if (amount < tokenConfig.minAmountToTrigger) { + revert Uniflow__MinAmountNotFulfilled(); + } + + if (tokenConfig.receiver != receiver) { + revert Uniflow__InvalidReceiverConfigured(); + } + + // Perform approval and transfer + IERC20(token).safeTransferFrom(owner(), address(this), amount); + IERC20(token).forceApprove(approvalContract, amount); + emit AcrossBridgeInitiated(token, receiver, amount); + (bool depositSuccess,) = depositContract.call(depositData); + if (!depositSuccess) { + revert Uniflow__AcrossBridgeDepositFailed(); + } + } + + function _performChainlinkCCIPBridgeOp(bytes calldata report) internal { + (address receiver, address token, uint256 amount, uint64 destinationChainSelector) = + abi.decode(report, (address, address, uint256, uint64)); + if (!s_allowlistedChains[destinationChainSelector]) { + revert Uniflow__CCIPChainNotAllowlisted(destinationChainSelector); + } + + _onlyConfiguredToken(token); + + TokenConfig memory tokenConfig = s_tokenConfig[token]; + + if (amount < tokenConfig.minAmountToTrigger) { + revert Uniflow__MinAmountNotFulfilled(); + } + + if (tokenConfig.receiver != receiver) { + revert Uniflow__InvalidReceiverConfigured(); + } + + Client.EVM2AnyMessage memory ccipMessage = _buildCCIPMessage(receiver, token, amount); + uint256 ccipFees = s_ccipRouter.getFee(destinationChainSelector, ccipMessage); + + uint256 requiredLinkBalance = ccipFees; + + if (token == address(s_linkToken)) { + requiredLinkBalance += amount; + } + + IERC20(token).safeTransferFrom(owner(), address(this), amount); + + if (s_linkToken.balanceOf(address(this)) < requiredLinkBalance) { + revert Uniflow__InsufficientLinkTokenForCCIPBridge(); + } + + s_linkToken.forceApprove(address(s_ccipRouter), requiredLinkBalance); + if (token != address(s_linkToken)) { + IERC20(token).forceApprove(address(s_ccipRouter), amount); + } + + bytes32 messageId = s_ccipRouter.ccipSend(destinationChainSelector, ccipMessage); + emit CCIPBridgeInitiated(messageId, token, receiver, amount); + } + + function _buildCCIPMessage(address receiver, address token, uint256 amount) + internal + view + returns (Client.EVM2AnyMessage memory) + { + Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1); + tokenAmounts[0] = Client.EVMTokenAmount({token: token, amount: amount}); + + return Client.EVM2AnyMessage({ + receiver: abi.encode(receiver), + data: "", + tokenAmounts: tokenAmounts, + feeToken: address(s_linkToken), + extraArgs: Client._argsToBytes(Client.GenericExtraArgsV2({gasLimit: 0, allowOutOfOrderExecution: true})) + }); + } + + function _processReport(bytes calldata report) internal override { + if (report.length == 0) { + revert Uniflow__EmptyReport(); + } + + bytes1 op = report[0]; + if (op == 0x01) { + _performAcrossBridgeOp(report[1:]); + } else if (op == 0x02) { + _performChainlinkCCIPBridgeOp(report[1:]); + } + } + + function _onlyConfiguredToken(address token) internal view { + if (s_tokenConfig[token].receiver == address(0)) { + revert Uniflow__TokenNotConfigured(); + } + } +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/IReceiver.sol b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/IReceiver.sol new file mode 100644 index 00000000..7704c499 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/IReceiver.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; + +/// @title IReceiver - receives keystone reports +/// @notice Implementations must support the IReceiver interface through ERC165. +interface IReceiver is IERC165 { + /// @notice Handles incoming keystone reports. + /// @dev If this function call reverts, it can be retried with a higher gas + /// limit. The receiver is responsible for discarding stale reports. + /// @param metadata Report's metadata. + /// @param report Workflow report. + function onReport(bytes calldata metadata, bytes calldata report) external; +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/ReceiverTemplate.sol b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/ReceiverTemplate.sol new file mode 100644 index 00000000..68d08794 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/contracts/src/interfaces/ReceiverTemplate.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import {IReceiver} from "./IReceiver.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @title ReceiverTemplate - Abstract receiver with optional permission controls +/// @notice Provides flexible, updatable security checks for receiving workflow reports +/// @dev The forwarder address is required at construction time for security. +/// Additional permission fields can be configured using setter functions. +abstract contract ReceiverTemplate is IReceiver, Ownable { + // Required permission field at deployment, configurable after + address private s_forwarderAddress; // If set, only this address can call onReport + + // Optional permission fields (all default to zero = disabled) + address private s_expectedAuthor; // If set, only reports from this workflow owner are accepted + bytes10 private s_expectedWorkflowName; // Only validated when s_expectedAuthor is also set + bytes32 private s_expectedWorkflowId; // If set, only reports from this specific workflow ID are accepted + + // Hex character lookup table for bytes-to-hex conversion + bytes private constant HEX_CHARS = "0123456789abcdef"; + + // Custom errors + error InvalidForwarderAddress(); + error InvalidSender(address sender, address expected); + error InvalidAuthor(address received, address expected); + error InvalidWorkflowName(bytes10 received, bytes10 expected); + error InvalidWorkflowId(bytes32 received, bytes32 expected); + error WorkflowNameRequiresAuthorValidation(); + + // Events + event ForwarderAddressUpdated(address indexed previousForwarder, address indexed newForwarder); + event ExpectedAuthorUpdated(address indexed previousAuthor, address indexed newAuthor); + event ExpectedWorkflowNameUpdated(bytes10 indexed previousName, bytes10 indexed newName); + event ExpectedWorkflowIdUpdated(bytes32 indexed previousId, bytes32 indexed newId); + event SecurityWarning(string message); + + /// @notice Constructor sets msg.sender as the owner and configures the forwarder address + /// @param _forwarderAddress The address of the Chainlink Forwarder contract (cannot be address(0)) + /// @dev The forwarder address is required for security - it ensures only verified reports are processed + constructor(address _forwarderAddress) Ownable(msg.sender) { + if (_forwarderAddress == address(0)) { + revert InvalidForwarderAddress(); + } + s_forwarderAddress = _forwarderAddress; + emit ForwarderAddressUpdated(address(0), _forwarderAddress); + } + + /// @notice Returns the configured forwarder address + /// @return The forwarder address (address(0) if disabled) + function getForwarderAddress() external view returns (address) { + return s_forwarderAddress; + } + + /// @notice Returns the expected workflow author address + /// @return The expected author address (address(0) if not set) + function getExpectedAuthor() external view returns (address) { + return s_expectedAuthor; + } + + /// @notice Returns the expected workflow name + /// @return The expected workflow name (bytes10(0) if not set) + function getExpectedWorkflowName() external view returns (bytes10) { + return s_expectedWorkflowName; + } + + /// @notice Returns the expected workflow ID + /// @return The expected workflow ID (bytes32(0) if not set) + function getExpectedWorkflowId() external view returns (bytes32) { + return s_expectedWorkflowId; + } + + /// @inheritdoc IReceiver + /// @dev Performs optional validation checks based on which permission fields are set + function onReport(bytes calldata metadata, bytes calldata report) external override { + // Security Check 1: Verify caller is the trusted Chainlink Forwarder (if configured) + if (s_forwarderAddress != address(0) && msg.sender != s_forwarderAddress) { + revert InvalidSender(msg.sender, s_forwarderAddress); + } + + // Security Checks 2-4: Verify workflow identity - ID, owner, and/or name (if any are configured) + if ( + s_expectedWorkflowId != bytes32(0) || s_expectedAuthor != address(0) || s_expectedWorkflowName != bytes10(0) + ) { + (bytes32 workflowId, bytes10 workflowName, address workflowOwner) = _decodeMetadata(metadata); + + if (s_expectedWorkflowId != bytes32(0) && workflowId != s_expectedWorkflowId) { + revert InvalidWorkflowId(workflowId, s_expectedWorkflowId); + } + if (s_expectedAuthor != address(0) && workflowOwner != s_expectedAuthor) { + revert InvalidAuthor(workflowOwner, s_expectedAuthor); + } + + // ================================================================ + // WORKFLOW NAME VALIDATION - REQUIRES AUTHOR VALIDATION + // ================================================================ + // Do not rely on workflow name validation alone. Workflow names are unique + // per owner, but not across owners. + // Furthermore, workflow names use 40-bit truncation (bytes10), making collisions possible. + // Therefore, workflow name validation REQUIRES author (workflow owner) validation. + // The code enforces this dependency at runtime. + // ================================================================ + if (s_expectedWorkflowName != bytes10(0)) { + // Author must be configured if workflow name is used + if (s_expectedAuthor == address(0)) { + revert WorkflowNameRequiresAuthorValidation(); + } + // Validate workflow name matches (author already validated above) + if (workflowName != s_expectedWorkflowName) { + revert InvalidWorkflowName(workflowName, s_expectedWorkflowName); + } + } + } + + _processReport(report); + } + + /// @notice Updates the forwarder address that is allowed to call onReport + /// @param _forwarder The new forwarder address + /// @dev WARNING: Setting to address(0) disables forwarder validation. + /// This makes your contract INSECURE - anyone can call onReport() with arbitrary data. + /// Only use address(0) if you fully understand the security implications. + function setForwarderAddress(address _forwarder) external onlyOwner { + address previousForwarder = s_forwarderAddress; + + // Emit warning if disabling forwarder check + if (_forwarder == address(0)) { + emit SecurityWarning("Forwarder address set to zero - contract is now INSECURE"); + } + + s_forwarderAddress = _forwarder; + emit ForwarderAddressUpdated(previousForwarder, _forwarder); + } + + /// @notice Updates the expected workflow owner address + /// @param _author The new expected author address (use address(0) to disable this check) + function setExpectedAuthor(address _author) external onlyOwner { + address previousAuthor = s_expectedAuthor; + s_expectedAuthor = _author; + emit ExpectedAuthorUpdated(previousAuthor, _author); + } + + /// @notice Updates the expected workflow name from a plaintext string + /// @param _name The workflow name as a string (use empty string "" to disable this check) + /// @dev IMPORTANT: Workflow name validation REQUIRES author validation to be enabled. + /// The workflow name uses only 40-bit truncation, making collision attacks feasible + /// when used alone. However, since workflow names are unique per owner, validating + /// both the name AND the author address provides adequate security. + /// You must call setExpectedAuthor() before or after calling this function. + /// The name is hashed using SHA256 and truncated to bytes10. + function setExpectedWorkflowName(string calldata _name) external onlyOwner { + bytes10 previousName = s_expectedWorkflowName; + + if (bytes(_name).length == 0) { + s_expectedWorkflowName = bytes10(0); + emit ExpectedWorkflowNameUpdated(previousName, bytes10(0)); + return; + } + + // Convert workflow name to bytes10: + // SHA256 hash → hex encode → take first 10 chars → hex encode those chars + bytes32 hash = sha256(bytes(_name)); + bytes memory hexString = _bytesToHexString(abi.encodePacked(hash)); + bytes memory first10 = new bytes(10); + for (uint256 i = 0; i < 10; i++) { + first10[i] = hexString[i]; + } + s_expectedWorkflowName = bytes10(first10); + emit ExpectedWorkflowNameUpdated(previousName, s_expectedWorkflowName); + } + + /// @notice Updates the expected workflow ID + /// @param _id The new expected workflow ID (use bytes32(0) to disable this check) + function setExpectedWorkflowId(bytes32 _id) external onlyOwner { + bytes32 previousId = s_expectedWorkflowId; + s_expectedWorkflowId = _id; + emit ExpectedWorkflowIdUpdated(previousId, _id); + } + + /// @notice Helper function to convert bytes to hex string + /// @param data The bytes to convert + /// @return The hex string representation + function _bytesToHexString(bytes memory data) private pure returns (bytes memory) { + bytes memory hexString = new bytes(data.length * 2); + + for (uint256 i = 0; i < data.length; i++) { + hexString[i * 2] = HEX_CHARS[uint8(data[i] >> 4)]; + hexString[i * 2 + 1] = HEX_CHARS[uint8(data[i] & 0x0f)]; + } + + return hexString; + } + + /// @notice Extracts all metadata fields from the onReport metadata parameter + /// @param metadata The metadata bytes encoded using abi.encodePacked(workflowId, workflowName, workflowOwner) + /// @return workflowId The unique identifier of the workflow (bytes32) + /// @return workflowName The name of the workflow (bytes10) + /// @return workflowOwner The owner address of the workflow + function _decodeMetadata(bytes memory metadata) + internal + pure + returns (bytes32 workflowId, bytes10 workflowName, address workflowOwner) + { + // Metadata structure (encoded using abi.encodePacked by the Forwarder): + // - First 32 bytes: length of the byte array (standard for dynamic bytes) + // - Offset 32, size 32: workflow_id (bytes32) + // - Offset 64, size 10: workflow_name (bytes10) + // - Offset 74, size 20: workflow_owner (address) + assembly { + workflowId := mload(add(metadata, 32)) + workflowName := mload(add(metadata, 64)) + workflowOwner := shr(mul(12, 8), mload(add(metadata, 74))) + } + return (workflowId, workflowName, workflowOwner); + } + + /// @notice Abstract function to process the report data + /// @param report The report calldata containing your workflow's encoded data + /// @dev Implement this function with your contract's business logic + function _processReport(bytes calldata report) internal virtual; + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public pure virtual override returns (bool) { + return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/IBridge.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/IBridge.ts new file mode 100644 index 00000000..f7ea1337 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/IBridge.ts @@ -0,0 +1,29 @@ +import { EVMClient, type Runtime } from "@chainlink/cre-sdk"; +import { IConfig } from "../interfaces/IConfig"; + +export interface BridgeInputProps { + runtime: Runtime; + evmClient: EVMClient; + from: { + chain: string; + token: string; + depositor: string; + }; + to: { + chain: string; + token: string; + recipient: string; + }; + amount: string | BigInt; + sourceConfigContract: string; +} + +export interface BridgeFactoryCCIPProps extends BridgeInputProps { + destinationChainSelector: string; +} + +export interface IBridge { + bridgeTokenService(props: BridgeInputProps): any; + addExtraParams(props: IConfig): any; + bridgeName(): string; +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/factory.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/factory.ts new file mode 100644 index 00000000..7d1209d7 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/factory.ts @@ -0,0 +1,16 @@ +import { IBridge } from "./IBridge"; +import { AcrossBridge } from "./integrations/across"; +import { ChainlinkCCIPBridge } from "./integrations/chainlinkCCIP"; + +export class BridgeFactory { + static getBridge(config: string): IBridge { + switch (config.toLowerCase()) { + case "across": + return new AcrossBridge(); + case "chainlink_ccip": + return new ChainlinkCCIPBridge(); + default: + throw new Error("Invalid bridge type"); + } + } +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/across.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/across.ts new file mode 100644 index 00000000..c7431c56 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/across.ts @@ -0,0 +1,118 @@ +import { consensusIdenticalAggregation, cre, HTTPSendRequester, ok, hexToBase64, TxStatus } from "@chainlink/cre-sdk"; +import { BridgeInputProps, IBridge } from "../IBridge"; +import { buildUrlWithParams } from "../../utils/buildUrlWithParams"; +import { parseAbiParameters, encodeAbiParameters, decodeFunctionData, erc20Abi, concatHex } from "viem"; +import { IConfig } from "../../interfaces/IConfig"; + +interface AcrossResponse { + approvalTxns: Array<{ + chainId: number; + to: string; + data: string; + }>; + swapTx: { + ecosystem: string; + simulationSuccess: boolean; + chainId: number; + to: string; + data: string; + }, +}; + +export class AcrossBridge implements IBridge { + bridgeTokenService(props: BridgeInputProps): any { + const httpClient = new cre.capabilities.HTTPClient(); + + const acrossBridgeRequestResponse = httpClient.sendRequest( + props.runtime, + this.initializeAcrossBridge(props), + consensusIdenticalAggregation() + )().result(); + + const approvalTxnData = acrossBridgeRequestResponse.approvalTxns?.[0].data as `0x${string}`; + const { args } = decodeFunctionData({ + abi: erc20Abi, + data: approvalTxnData, + }); + const [approvalContract] = args; + + // send report to config contract to process token approval and bridging + const acrossBridgeParams = parseAbiParameters("address receiver, uint256 amount, address token, address approvalContract, address depositContract, bytes memory depositData"); + const encodedBridgeParams = encodeAbiParameters( + acrossBridgeParams, + [ + props.to.recipient as `0x${string}`, + BigInt(props.amount.toString()), + props.from.token as `0x${string}`, + approvalContract as `0x${string}`, + acrossBridgeRequestResponse.swapTx.to as `0x${string}`, + acrossBridgeRequestResponse.swapTx.data as `0x${string}` + ], + ); + + const signedReport = props.runtime.report({ + encodedPayload: hexToBase64(concatHex(["0x01", encodedBridgeParams])), + encoderName: "evm", + signingAlgo: "ecdsa", + hashingAlgo: "keccak256", + }).result(); + + const bridgeReportResponse = props.evmClient.writeReport(props.runtime, { + receiver: props.sourceConfigContract, + report: signedReport, + gasConfig: { + gasLimit: "1000000", + }, + }).result(); + + if (bridgeReportResponse.txStatus == TxStatus.SUCCESS) { + return { success: true, txHash: bridgeReportResponse.txHash, acrossBridgeRequest: acrossBridgeRequestResponse, report: concatHex(["0x01", encodedBridgeParams]) }; + } + + return { success: false, error: bridgeReportResponse.errorMessage, acrossBridgeRequest: acrossBridgeRequestResponse }; + } + + private initializeAcrossBridge(props: BridgeInputProps) { + return (sendRequester: HTTPSendRequester): AcrossResponse => { + const params = { + tradeType: 'exactInput', + amount: props.amount.toString(), + inputToken: props.from.token, + originChainId: props.from.chain, + outputToken: props.to.token, + destinationChainId: props.to.chain, + depositor: props.from.depositor, + recipient: props.to.recipient, + }; + + const swapUrl = `${props.runtime.config.acrossApiUrl}/api/swap/approval`; + + const req = { + url: buildUrlWithParams(swapUrl, params), + method: "GET" as const, + cacheSettings: { + store: true, + maxAge: '60s', + }, + }; + + const resp = sendRequester.sendRequest(req).result(); + const bodyText = new TextDecoder().decode(resp.body); + + if (!ok(resp)) { + throw new Error(`Across bridge initialize request error: ${resp.statusCode} - ${bodyText}`); + } + + const apiResponse = JSON.parse(bodyText) as AcrossResponse; + return apiResponse; + } + } + + addExtraParams(props: IConfig) { + return {}; + } + + bridgeName(): string { + return "Across"; + } +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/chainlinkCCIP.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/chainlinkCCIP.ts new file mode 100644 index 00000000..348497ce --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/chainlinkCCIP.ts @@ -0,0 +1,50 @@ +import { hexToBase64, TxStatus } from "@chainlink/cre-sdk"; +import { BridgeFactoryCCIPProps, BridgeInputProps, IBridge } from "../IBridge"; +import { parseAbiParameters, encodeAbiParameters, decodeFunctionData, erc20Abi, concatHex } from "viem"; +import { IConfig } from "../../interfaces/IConfig"; + +export class ChainlinkCCIPBridge implements IBridge { + bridgeTokenService(props: BridgeFactoryCCIPProps): any { + const ccipReportParams = parseAbiParameters("address receiver, address token, uint256 amount, uint64 unichainSelector"); + const encodedReportParams = encodeAbiParameters( + ccipReportParams, + [ + props.to.recipient as `0x${string}`, + props.from.token as `0x${string}`, + BigInt(props.amount.toString()), + BigInt(props.destinationChainSelector) + ] + ); + + const signedReport = props.runtime.report({ + encodedPayload: hexToBase64(concatHex(["0x02", encodedReportParams])), + encoderName: "evm", + signingAlgo: "ecdsa", + hashingAlgo: "keccak256" + }).result(); + + const bridgeReportResponse = props.evmClient.writeReport(props.runtime, { + receiver: props.sourceConfigContract, + report: signedReport, + gasConfig: { + gasLimit: "500000" + } + }).result(); + + if (bridgeReportResponse.txStatus == TxStatus.SUCCESS) { + return { success: true, txHash: bridgeReportResponse.txHash }; + } + + return { success: false, error: bridgeReportResponse.errorMessage }; + } + + addExtraParams(props: IConfig): Omit { + return { + destinationChainSelector: props.unichain.chainlinkCCIPSelector + }; + } + + bridgeName(): string { + return "Chainlink CCIP"; + } +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.production.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.production.json new file mode 100644 index 00000000..f0e8d85b --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.production.json @@ -0,0 +1,31 @@ +{ + "networks": { + "eth": { + "chainId": "11155111", + "creNetworkConfig": [ + { + "chainFamily": "evm", + "chainSelectorName": "ethereum-testnet-sepolia", + "isTestnet": true + } + ], + "configContract": "0x4d236Ec82Af6c73835F29BBdc4b34574a0E0FdaE", + "targetUserAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "tokenArr": ["0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"], + "tokenMap": { + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": { + "unichainToken": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "bridge": "chainlink_ccip" + } + } + } + }, + "unichain": { + "chainId": "84532", + "unichainDestinationAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "configContract": "", + "chainlinkCCIPSelector": "10344971235874465080" + }, + "telegramMessageApi": "https://api.telegram.org/bot{{TELEGRAM_BOT_ACCESS_TOKEN}}/sendMessage?chat_id={{TELEGRAM_CHAT_ID}}&text={{MESSAGE}}", + "acrossApiUrl": "https://testnet.across.to" +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.staging.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.staging.json new file mode 100644 index 00000000..f0e8d85b --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/config.staging.json @@ -0,0 +1,31 @@ +{ + "networks": { + "eth": { + "chainId": "11155111", + "creNetworkConfig": [ + { + "chainFamily": "evm", + "chainSelectorName": "ethereum-testnet-sepolia", + "isTestnet": true + } + ], + "configContract": "0x4d236Ec82Af6c73835F29BBdc4b34574a0E0FdaE", + "targetUserAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "tokenArr": ["0x1c7d4b196cb0c7b01d743fbc6116a902379c7238"], + "tokenMap": { + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": { + "unichainToken": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "bridge": "chainlink_ccip" + } + } + } + }, + "unichain": { + "chainId": "84532", + "unichainDestinationAddress": "0xF1c8170181364DeD1C56c4361DED2eB47f2eef1b", + "configContract": "", + "chainlinkCCIPSelector": "10344971235874465080" + }, + "telegramMessageApi": "https://api.telegram.org/bot{{TELEGRAM_BOT_ACCESS_TOKEN}}/sendMessage?chat_id={{TELEGRAM_CHAT_ID}}&text={{MESSAGE}}", + "acrossApiUrl": "https://testnet.across.to" +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/ethereumOnReceiveToken.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/ethereumOnReceiveToken.ts new file mode 100644 index 00000000..1c870ab8 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/ethereumOnReceiveToken.ts @@ -0,0 +1,69 @@ +import { cre, type Runtime, type EVMLog, bytesToHex, getNetwork } from "@chainlink/cre-sdk"; +import { IConfig } from "./interfaces/IConfig" +import { parseAbi, decodeEventLog } from "viem"; +import { sendTelegramMessage } from "./telegramMessageService"; +import { BridgeFactory } from "./bridge/factory" +import { getEvmClient } from "./utils/getEvmClient"; +import { getERC20Decimals, getERC20Allowance } from "./utils/erc20Utils"; + +const TRANSFER_EVENT_ABI = parseAbi(["event Transfer(address indexed from, address indexed to, uint256 amount)"]); + +export const ethereumOnReceiveToken = (runtime: Runtime, evmLog: EVMLog): any => { + const topics = evmLog.topics.map((t: Uint8Array) => bytesToHex(t)) as [ + `0x${string}`, + ...`0x${string}`[], + ]; + + const data = bytesToHex(evmLog.data); + const decodedLog = decodeEventLog({abi: TRANSFER_EVENT_ABI, topics: topics, data: data}); + + runtime.log(`To: ${decodedLog.args.to}`); + runtime.log(`Amount: ${decodedLog.args.amount}`); + runtime.log(`Event Sig: ${bytesToHex(evmLog.eventSig)}`); + + const networksConfig = runtime.config.networks; + const { evmClient } = getEvmClient(...networksConfig['eth'].creNetworkConfig); + + const tokenDecimals = getERC20Decimals(bytesToHex(evmLog.address).toString(), runtime, evmClient); + sendTelegramMessage(runtime, `Token sent by: ${decodedLog.args.from} of amount ${decodedLog.args.amount}, decimals ${tokenDecimals} on ${bytesToHex(evmLog.address)}`); + + const allowances = getERC20Allowance(bytesToHex(evmLog.address).toString(), runtime, evmClient, { owner: decodedLog.args.to, spender: networksConfig['eth'].configContract }); + + runtime.log(`Allowance to config contract for token: ${allowances.toString()}`); + + if (allowances < decodedLog.args.amount) { + return { message: "error", error: "Insufficient allowance for config contract" }; + } + + const tokenAddress = bytesToHex(evmLog.address).toString() as `0x${string}`; + const unichainTokenConfig = runtime.config.networks['eth'].tokenMap[tokenAddress]; + const bridge = BridgeFactory.getBridge(unichainTokenConfig.bridge); + + runtime.log(`Bridging via: ${bridge.bridgeName()}`); + + const extraParams = bridge.addExtraParams(runtime.config); + const { success, txHash, error } = bridge.bridgeTokenService({ + runtime, + evmClient, + from: { + chain: "11155111", + token: tokenAddress, + depositor: networksConfig['eth'].configContract, + }, + to: { + chain: runtime.config.unichain.chainId, + token: unichainTokenConfig.unichainToken, + recipient: runtime.config.unichain.unichainDestinationAddress, + }, + amount: decodedLog.args.amount, + sourceConfigContract: networksConfig['eth'].configContract, + ...extraParams + }); + + if (success) { + return { message: "success", txHash: txHash || "", error: error || "" }; + } + + runtime.log(`error: ${error}`); + return { message: "error", error: error }; +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/interfaces/IConfig.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/interfaces/IConfig.ts new file mode 100644 index 00000000..a31c1862 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/interfaces/IConfig.ts @@ -0,0 +1,27 @@ +import { getNetwork } from "@chainlink/cre-sdk"; + +export interface IConfig { + networks: { + [key: string]: { + chainId: string; + creNetworkConfig: Parameters; + configContract: string; + targetUserAddress: string; + tokenArr: Array; + tokenMap: { + [key: `0x${string}`]: { + unichainToken: `0x${string}`; + bridge: string; + }; + }, + }; + }; + unichain: { + chainId: string; + unichainDestinationAddress: string; + configContract: string; + chainlinkCCIPSelector: string; + }; + telegramMessageApi: string; + acrossApiUrl: string; +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/main.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/main.ts new file mode 100644 index 00000000..d11b7148 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/main.ts @@ -0,0 +1,38 @@ +import { cre, Runner } from "@chainlink/cre-sdk"; +import { getEvmClient } from "./utils/getEvmClient"; +import { IConfig } from "./interfaces/IConfig" +import { ethereumOnReceiveToken } from "./ethereumOnReceiveToken"; +import { toHex, keccak256 } from "viem"; + +const initWorkflow = (config: IConfig) => { + const networksConfig = config.networks; + const { evmClient: ethereumClient } = getEvmClient(...networksConfig['eth'].creNetworkConfig); + const TRANSFER_EVENT = keccak256(toHex("Transfer(address,address,uint256)")); + const ethereumLogTrigger = getLogTrigger(ethereumClient, TRANSFER_EVENT, networksConfig['eth'].targetUserAddress, networksConfig['eth'].tokenArr); + + return [ + cre.handler( + ethereumLogTrigger, + ethereumOnReceiveToken, + ), + ]; +}; + +const getLogTrigger = (evmClient: InstanceType, transferEvent: `0x${string}`, toAddress: string, token: Array) => { + return evmClient.logTrigger({ + addresses: [...token], + topics: [ + { values: [transferEvent] }, + { values: [] }, + { values: [toAddress] }, + ], + confidence: 'CONFIDENCE_LEVEL_FINALIZED', + }); +} + +export async function main() { + const runner = await Runner.newRunner(); + await runner.run(initWorkflow); +} + +main(); diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package.json new file mode 100644 index 00000000..f9a127ab --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package.json @@ -0,0 +1,18 @@ +{ + "name": "cross-chain-token-aggregator-ts", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bun x cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.5.0", + "viem": "2.34.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/telegramMessageService.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/telegramMessageService.ts new file mode 100644 index 00000000..5dbdb639 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/telegramMessageService.ts @@ -0,0 +1,41 @@ +import { cre, type Runtime, consensusIdenticalAggregation, HTTPSendRequester, ok } from "@chainlink/cre-sdk"; +import { IConfig } from "./interfaces/IConfig"; + +// to get telegram token, make the user message on your telegram, then get chat id and store on chain +// if already present then send to that chat id + +export const sendTelegramMessage = (runtime: Runtime, message: string) => { + try { + const chatId = runtime.getSecret({id: "TELEGRAM_CHAT_ID"}).result().value; + const botToken = runtime.getSecret({id: "TELEGRAM_BOT_ACCESS_TOKEN"}).result().value; + const httpClient = new cre.capabilities.HTTPClient(); + + httpClient.sendRequest( + runtime, + _sendTelegramMessage, + consensusIdenticalAggregation() + )(runtime.config, chatId, message, botToken).result(); + } + catch (error) { + runtime.log("Error sending notification on telegram, continuing workflow..."); + } +} + +const _sendTelegramMessage = (sendRequester: HTTPSendRequester, config: IConfig, chatId: string, message: string, botToken: string): boolean => { + let telegramUrl = config.telegramMessageApi; + telegramUrl = telegramUrl.replace("{{TELEGRAM_BOT_ACCESS_TOKEN}}", botToken); + telegramUrl = telegramUrl.replace("{{TELEGRAM_CHAT_ID}}", chatId); + telegramUrl = telegramUrl.replace("{{MESSAGE}}", message); + + const messageReq = { + url: telegramUrl, + method: "GET" as const, + cacheSettings: { + store: true, + maxAge: '60s', + }, + }; + + const result = sendRequester.sendRequest(messageReq).result(); + return ok(result); +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/tsconfig.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/tsconfig.json new file mode 100644 index 00000000..840fdc79 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext"], + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "main.ts" + ] +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/buildUrlWithParams.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/buildUrlWithParams.ts new file mode 100644 index 00000000..ad7def3e --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/buildUrlWithParams.ts @@ -0,0 +1,14 @@ +export const buildUrlWithParams = (url: string, params: Record) => { + const keys = Object.keys(params); + if (!keys.length) { + return url; + } + + let formattedUrl = url; + + keys.forEach((key: string, index: number) => { + formattedUrl += `${index == 0 ? "?" : "&"}${key}=${params[key]}` + }); + + return formattedUrl; +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/erc20Utils.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/erc20Utils.ts new file mode 100644 index 00000000..1bb4bfbb --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/erc20Utils.ts @@ -0,0 +1,52 @@ +import { type Runtime, encodeCallMsg, EVMClient, LAST_FINALIZED_BLOCK_NUMBER } from "@chainlink/cre-sdk"; +import { bytesToHex, decodeFunctionResult, encodeFunctionData, erc20Abi, zeroAddress } from "viem"; +import { IConfig } from "../interfaces/IConfig"; + +export const getERC20Decimals = (token: string, runtime: Runtime, evmClient: EVMClient) => { + const callData = encodeFunctionData({ + abi: erc20Abi, + functionName: "decimals", + }); + + const result = evmClient.callContract(runtime, { + call: encodeCallMsg({ + from: zeroAddress, + to: token as `0x${string}`, + data: callData, + }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }).result(); + + const decimals = decodeFunctionResult({ + abi: erc20Abi, + functionName: 'decimals', + data: bytesToHex(result.data) + }); + + return decimals; +} + +export const getERC20Allowance = (token: string, runtime: Runtime, evmCLient: EVMClient, args: { owner: string; spender: string; }) => { + const callData = encodeFunctionData({ + abi: erc20Abi, + functionName: 'allowance', + args: [args.owner as `0x${string}`, args.spender as `0x${string}`] + }); + + const result = evmCLient.callContract(runtime, { + call: encodeCallMsg({ + from: zeroAddress, + to: token as `0x${string}`, + data: callData + }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }).result(); + + const allowance = decodeFunctionResult({ + abi: erc20Abi, + functionName: 'allowance', + data: bytesToHex(result.data) + }) + + return allowance; +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/getEvmClient.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/getEvmClient.ts new file mode 100644 index 00000000..c587b89d --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/utils/getEvmClient.ts @@ -0,0 +1,12 @@ +import { cre, getNetwork } from "@chainlink/cre-sdk"; + +export const getEvmClient = (...args: Parameters): { network: ReturnType, evmClient: InstanceType } => { + const network = getNetwork(...args); + if (!network) { + throw new Error("Network config incorrect"); + } + return { + network, + evmClient: new cre.capabilities.EVMClient(network.chainSelector.selector), + } +} \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/workflow.yaml b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/workflow.yaml new file mode 100644 index 00000000..2b32cbcd --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/workflow.yaml @@ -0,0 +1,34 @@ +# ========================================================================== +# CRE WORKFLOW SETTINGS FILE +# ========================================================================== +# Workflow-specific settings for CRE CLI targets. +# Each target defines user-workflow and workflow-artifacts groups. +# Settings here override CRE Project Settings File values. +# +# Example custom target: +# my-target: +# user-workflow: +# workflow-name: "MyExampleWorkflow" # Required: Workflow Registry name +# workflow-artifacts: +# workflow-path: "./main.ts" # Path to workflow entry point +# config-path: "./config.yaml" # Path to config file +# secrets-path: "../secrets.yaml" # Path to secrets file (project root by default) + +# ========================================================================== +staging-settings: + user-workflow: + workflow-name: "my-workflow-staging" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.staging.json" + secrets-path: "../secrets.yaml" + + +# ========================================================================== +production-settings: + user-workflow: + workflow-name: "my-workflow-production" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.production.json" + secrets-path: "../secrets.yaml" \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/project.yaml b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/project.yaml new file mode 100644 index 00000000..2dc2c5e4 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/project.yaml @@ -0,0 +1,27 @@ +# ========================================================================== +# CRE PROJECT SETTINGS FILE +# ========================================================================== +# Project-specific settings for CRE CLI targets. +# Each target defines cre-cli, account, and rpcs groups. +# +# Example custom target: +# my-target: +# account: +# workflow-owner-address: "0x123..." # Optional: Owner wallet/MSIG address (used for --unsigned transactions) +# rpcs: +# - chain-name: ethereum-testnet-sepolia # Required if your workflow interacts with this chain +# url: "" + +# ========================================================================== +staging-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com + +# ========================================================================== +production-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/secrets.yaml b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/secrets.yaml new file mode 100644 index 00000000..32498130 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/secrets.yaml @@ -0,0 +1,5 @@ +secretsNames: + TELEGRAM_BOT_ACCESS_TOKEN: + - TELEGRAM_BOT_ACCESS_TOKEN_VAR + TELEGRAM_CHAT_ID: + - TELEGRAM_CHAT_ID_VAR \ No newline at end of file