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..fa3c7398 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/bridge/integrations/across.ts @@ -0,0 +1,124 @@ +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; + + props.runtime.log(`Approval Contract for Across Bridge: ${approvalContract}`); + + // 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(); + + props.runtime.log(`Report receiver: ${props.sourceConfigContract}`); + + const bridgeReportResponse = props.evmClient.writeReport(props.runtime, { + receiver: props.sourceConfigContract, + report: signedReport, + gasConfig: { + gasLimit: "1000000", + }, + }).result(); + + props.runtime.log(`${bridgeReportResponse.receiverContractExecutionStatus}`); + + 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..b9e7f211 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/ethereumOnReceiveToken.ts @@ -0,0 +1,75 @@ +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 { Uniflow } from "./evm/generated/Uniflow"; +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(decodedLog.args.to); + runtime.log(`${decodedLog.args.amount}`); + runtime.log(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)}`); + + // @todo check for approval given by from address to the config contract + 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 uniflowContract = new Uniflow(evmClient, networksConfig['eth'].configContract as `0x${string}`); + const owner = uniflowContract.owner(runtime); + runtime.log(owner); + + 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/evm/abi/Uniflow.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/abi/Uniflow.json new file mode 100644 index 00000000..4999d120 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/abi/Uniflow.json @@ -0,0 +1,663 @@ +[ + { + "type": "constructor", + "inputs": [ + { + "name": "_forwarderAddress", + "type": "address", + "internalType": "address" + }, + { + "name": "_ccipRouter", + "type": "address", + "internalType": "address" + }, + { + "name": "_linkToken", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "allowlistDestanationChainForCCIP", + "inputs": [ + { + "name": "selector", + "type": "uint64", + "internalType": "uint64" + }, + { + "name": "enable", + "type": "bool", + "internalType": "bool" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "getExpectedAuthor", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getExpectedWorkflowId", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getExpectedWorkflowName", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "bytes10", + "internalType": "bytes10" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getForwarderAddress", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "onReport", + "inputs": [ + { + "name": "metadata", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "report", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "owner", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "renounceOwnership", + "inputs": [], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "s_allowlistedChains", + "inputs": [ + { + "name": "", + "type": "uint64", + "internalType": "uint64" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s_ccipRouter", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IRouterClient" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s_linkToken", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "s_tokenConfig", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "minAmountToTrigger", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "setExpectedAuthor", + "inputs": [ + { + "name": "_author", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setExpectedWorkflowId", + "inputs": [ + { + "name": "_id", + "type": "bytes32", + "internalType": "bytes32" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setExpectedWorkflowName", + "inputs": [ + { + "name": "_name", + "type": "string", + "internalType": "string" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setForwarderAddress", + "inputs": [ + { + "name": "_forwarder", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "setupToken", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "tokenConfig", + "type": "tuple", + "internalType": "struct Uniflow.TokenConfig", + "components": [ + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "minAmountToTrigger", + "type": "uint256", + "internalType": "uint256" + } + ] + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "supportsInterface", + "inputs": [ + { + "name": "interfaceId", + "type": "bytes4", + "internalType": "bytes4" + } + ], + "outputs": [ + { + "name": "", + "type": "bool", + "internalType": "bool" + } + ], + "stateMutability": "pure" + }, + { + "type": "function", + "name": "transferOwnership", + "inputs": [ + { + "name": "newOwner", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "updateReceiver", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "AcrossBridgeInitiated", + "inputs": [ + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "CCIPBridgeInitiated", + "inputs": [ + { + "name": "messageId", + "type": "bytes32", + "indexed": false, + "internalType": "bytes32" + }, + { + "name": "token", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ExpectedAuthorUpdated", + "inputs": [ + { + "name": "previousAuthor", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newAuthor", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ExpectedWorkflowIdUpdated", + "inputs": [ + { + "name": "previousId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + }, + { + "name": "newId", + "type": "bytes32", + "indexed": true, + "internalType": "bytes32" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ExpectedWorkflowNameUpdated", + "inputs": [ + { + "name": "previousName", + "type": "bytes10", + "indexed": true, + "internalType": "bytes10" + }, + { + "name": "newName", + "type": "bytes10", + "indexed": true, + "internalType": "bytes10" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "ForwarderAddressUpdated", + "inputs": [ + { + "name": "previousForwarder", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newForwarder", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "OwnershipTransferred", + "inputs": [ + { + "name": "previousOwner", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "newOwner", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "SecurityWarning", + "inputs": [ + { + "name": "message", + "type": "string", + "indexed": false, + "internalType": "string" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "UniflowApproval", + "inputs": [ + { + "name": "user", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "approval", + "type": "bool", + "indexed": false, + "internalType": "bool" + } + ], + "anonymous": false + }, + { + "type": "error", + "name": "InvalidAuthor", + "inputs": [ + { + "name": "received", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidForwarderAddress", + "inputs": [] + }, + { + "type": "error", + "name": "InvalidSender", + "inputs": [ + { + "name": "sender", + "type": "address", + "internalType": "address" + }, + { + "name": "expected", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "InvalidWorkflowId", + "inputs": [ + { + "name": "received", + "type": "bytes32", + "internalType": "bytes32" + }, + { + "name": "expected", + "type": "bytes32", + "internalType": "bytes32" + } + ] + }, + { + "type": "error", + "name": "InvalidWorkflowName", + "inputs": [ + { + "name": "received", + "type": "bytes10", + "internalType": "bytes10" + }, + { + "name": "expected", + "type": "bytes10", + "internalType": "bytes10" + } + ] + }, + { + "type": "error", + "name": "OwnableInvalidOwner", + "inputs": [ + { + "name": "owner", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "OwnableUnauthorizedAccount", + "inputs": [ + { + "name": "account", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "SafeERC20FailedOperation", + "inputs": [ + { + "name": "token", + "type": "address", + "internalType": "address" + } + ] + }, + { + "type": "error", + "name": "Uniflow__AcrossBridgeDepositFailed", + "inputs": [] + }, + { + "type": "error", + "name": "Uniflow__CCIPChainNotAllowlisted", + "inputs": [ + { + "name": "chainSelector", + "type": "uint64", + "internalType": "uint64" + } + ] + }, + { + "type": "error", + "name": "Uniflow__EmptyReport", + "inputs": [] + }, + { + "type": "error", + "name": "Uniflow__InsufficientLinkTokenForCCIPBridge", + "inputs": [] + }, + { + "type": "error", + "name": "Uniflow__InvalidReceiverConfigured", + "inputs": [] + }, + { + "type": "error", + "name": "Uniflow__MinAmountNotFulfilled", + "inputs": [] + }, + { + "type": "error", + "name": "Uniflow__TokenNotConfigured", + "inputs": [] + }, + { + "type": "error", + "name": "WorkflowNameRequiresAuthorValidation", + "inputs": [] + } +] \ No newline at end of file diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow.ts new file mode 100644 index 00000000..d580de7a --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow.ts @@ -0,0 +1,1278 @@ +// Code generated — DO NOT EDIT. +import { + decodeEventLog, + decodeFunctionResult, + encodeEventTopics, + encodeFunctionData, + zeroAddress, +} from 'viem' +import type { Address, Hex } from 'viem' +import { + bytesToHex, + encodeCallMsg, + EVMClient, + hexToBase64, + LAST_FINALIZED_BLOCK_NUMBER, + prepareReportRequest, + type EVMLog, + type Runtime, +} from '@chainlink/cre-sdk' + +export interface DecodedLog extends Omit { data: T } + + + + + +/** + * Filter params for AcrossBridgeInitiated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type AcrossBridgeInitiatedTopics = { +} + +/** + * Decoded AcrossBridgeInitiated event data. + */ +export type AcrossBridgeInitiatedDecoded = { + token: `0x${string}` + receiver: `0x${string}` + amount: bigint +} + + +/** + * Filter params for CCIPBridgeInitiated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type CCIPBridgeInitiatedTopics = { +} + +/** + * Decoded CCIPBridgeInitiated event data. + */ +export type CCIPBridgeInitiatedDecoded = { + messageId: `0x${string}` + token: `0x${string}` + receiver: `0x${string}` + amount: bigint +} + + +/** + * Filter params for ExpectedAuthorUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedAuthorUpdatedTopics = { + previousAuthor?: `0x${string}` + newAuthor?: `0x${string}` +} + +/** + * Decoded ExpectedAuthorUpdated event data. + */ +export type ExpectedAuthorUpdatedDecoded = { + previousAuthor: `0x${string}` + newAuthor: `0x${string}` +} + + +/** + * Filter params for ExpectedWorkflowIdUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedWorkflowIdUpdatedTopics = { + previousId?: `0x${string}` + newId?: `0x${string}` +} + +/** + * Decoded ExpectedWorkflowIdUpdated event data. + */ +export type ExpectedWorkflowIdUpdatedDecoded = { + previousId: `0x${string}` + newId: `0x${string}` +} + + +/** + * Filter params for ExpectedWorkflowNameUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedWorkflowNameUpdatedTopics = { + previousName?: `0x${string}` + newName?: `0x${string}` +} + +/** + * Decoded ExpectedWorkflowNameUpdated event data. + */ +export type ExpectedWorkflowNameUpdatedDecoded = { + previousName: `0x${string}` + newName: `0x${string}` +} + + +/** + * Filter params for ForwarderAddressUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ForwarderAddressUpdatedTopics = { + previousForwarder?: `0x${string}` + newForwarder?: `0x${string}` +} + +/** + * Decoded ForwarderAddressUpdated event data. + */ +export type ForwarderAddressUpdatedDecoded = { + previousForwarder: `0x${string}` + newForwarder: `0x${string}` +} + + +/** + * Filter params for OwnershipTransferred. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type OwnershipTransferredTopics = { + previousOwner?: `0x${string}` + newOwner?: `0x${string}` +} + +/** + * Decoded OwnershipTransferred event data. + */ +export type OwnershipTransferredDecoded = { + previousOwner: `0x${string}` + newOwner: `0x${string}` +} + + +/** + * Filter params for SecurityWarning. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type SecurityWarningTopics = { +} + +/** + * Decoded SecurityWarning event data. + */ +export type SecurityWarningDecoded = { + message: string +} + + +/** + * Filter params for UniflowApproval. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type UniflowApprovalTopics = { +} + +/** + * Decoded UniflowApproval event data. + */ +export type UniflowApprovalDecoded = { + user: `0x${string}` + approval: boolean +} + + +export const UniflowABI = [{"type":"constructor","inputs":[{"name":"_forwarderAddress","type":"address","internalType":"address"},{"name":"_ccipRouter","type":"address","internalType":"address"},{"name":"_linkToken","type":"address","internalType":"address"}],"stateMutability":"nonpayable"},{"type":"function","name":"allowlistDestanationChainForCCIP","inputs":[{"name":"selector","type":"uint64","internalType":"uint64"},{"name":"enable","type":"bool","internalType":"bool"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"getExpectedAuthor","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowId","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowName","inputs":[],"outputs":[{"name":"","type":"bytes10","internalType":"bytes10"}],"stateMutability":"view"},{"type":"function","name":"getForwarderAddress","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"onReport","inputs":[{"name":"metadata","type":"bytes","internalType":"bytes"},{"name":"report","type":"bytes","internalType":"bytes"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"owner","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"renounceOwnership","inputs":[],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"s_allowlistedChains","inputs":[{"name":"","type":"uint64","internalType":"uint64"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"s_ccipRouter","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contractIRouterClient"}],"stateMutability":"view"},{"type":"function","name":"s_linkToken","inputs":[],"outputs":[{"name":"","type":"address","internalType":"contractIERC20"}],"stateMutability":"view"},{"type":"function","name":"s_tokenConfig","inputs":[{"name":"token","type":"address","internalType":"address"}],"outputs":[{"name":"receiver","type":"address","internalType":"address"},{"name":"minAmountToTrigger","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"setExpectedAuthor","inputs":[{"name":"_author","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowId","inputs":[{"name":"_id","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowName","inputs":[{"name":"_name","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setForwarderAddress","inputs":[{"name":"_forwarder","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setupToken","inputs":[{"name":"token","type":"address","internalType":"address"},{"name":"tokenConfig","type":"tuple","internalType":"structUniflow.TokenConfig","components":[{"name":"receiver","type":"address","internalType":"address"},{"name":"minAmountToTrigger","type":"uint256","internalType":"uint256"}]}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"pure"},{"type":"function","name":"transferOwnership","inputs":[{"name":"newOwner","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"updateReceiver","inputs":[{"name":"token","type":"address","internalType":"address"},{"name":"receiver","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"AcrossBridgeInitiated","inputs":[{"name":"token","type":"address","indexed":false,"internalType":"address"},{"name":"receiver","type":"address","indexed":false,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"CCIPBridgeInitiated","inputs":[{"name":"messageId","type":"bytes32","indexed":false,"internalType":"bytes32"},{"name":"token","type":"address","indexed":false,"internalType":"address"},{"name":"receiver","type":"address","indexed":false,"internalType":"address"},{"name":"amount","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"ExpectedAuthorUpdated","inputs":[{"name":"previousAuthor","type":"address","indexed":true,"internalType":"address"},{"name":"newAuthor","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowIdUpdated","inputs":[{"name":"previousId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newId","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowNameUpdated","inputs":[{"name":"previousName","type":"bytes10","indexed":true,"internalType":"bytes10"},{"name":"newName","type":"bytes10","indexed":true,"internalType":"bytes10"}],"anonymous":false},{"type":"event","name":"ForwarderAddressUpdated","inputs":[{"name":"previousForwarder","type":"address","indexed":true,"internalType":"address"},{"name":"newForwarder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"OwnershipTransferred","inputs":[{"name":"previousOwner","type":"address","indexed":true,"internalType":"address"},{"name":"newOwner","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"SecurityWarning","inputs":[{"name":"message","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"UniflowApproval","inputs":[{"name":"user","type":"address","indexed":false,"internalType":"address"},{"name":"approval","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"error","name":"InvalidAuthor","inputs":[{"name":"received","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidForwarderAddress","inputs":[]},{"type":"error","name":"InvalidSender","inputs":[{"name":"sender","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidWorkflowId","inputs":[{"name":"received","type":"bytes32","internalType":"bytes32"},{"name":"expected","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"InvalidWorkflowName","inputs":[{"name":"received","type":"bytes10","internalType":"bytes10"},{"name":"expected","type":"bytes10","internalType":"bytes10"}]},{"type":"error","name":"OwnableInvalidOwner","inputs":[{"name":"owner","type":"address","internalType":"address"}]},{"type":"error","name":"OwnableUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"}]},{"type":"error","name":"SafeERC20FailedOperation","inputs":[{"name":"token","type":"address","internalType":"address"}]},{"type":"error","name":"Uniflow__AcrossBridgeDepositFailed","inputs":[]},{"type":"error","name":"Uniflow__CCIPChainNotAllowlisted","inputs":[{"name":"chainSelector","type":"uint64","internalType":"uint64"}]},{"type":"error","name":"Uniflow__EmptyReport","inputs":[]},{"type":"error","name":"Uniflow__InsufficientLinkTokenForCCIPBridge","inputs":[]},{"type":"error","name":"Uniflow__InvalidReceiverConfigured","inputs":[]},{"type":"error","name":"Uniflow__MinAmountNotFulfilled","inputs":[]},{"type":"error","name":"Uniflow__TokenNotConfigured","inputs":[]},{"type":"error","name":"WorkflowNameRequiresAuthorValidation","inputs":[]}] as const + +export class Uniflow { + constructor( + private readonly client: EVMClient, + public readonly address: Address, + ) {} + + getExpectedAuthor( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'getExpectedAuthor' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'getExpectedAuthor' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getExpectedWorkflowId( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'getExpectedWorkflowId' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'getExpectedWorkflowId' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getExpectedWorkflowName( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'getExpectedWorkflowName' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'getExpectedWorkflowName' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getForwarderAddress( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'getForwarderAddress' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'getForwarderAddress' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + owner( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'owner' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'owner' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + sAllowlistedChains( + runtime: Runtime, + arg0: bigint, + ): boolean { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 's_allowlistedChains' as const, + args: [arg0], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 's_allowlistedChains' as const, + data: bytesToHex(result.data), + }) as boolean + } + + sCcipRouter( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 's_ccipRouter' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 's_ccipRouter' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + sLinkToken( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 's_linkToken' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 's_linkToken' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + sTokenConfig( + runtime: Runtime, + token: `0x${string}`, + ): readonly [`0x${string}`, bigint] { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 's_tokenConfig' as const, + args: [token], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 's_tokenConfig' as const, + data: bytesToHex(result.data), + }) as readonly [`0x${string}`, bigint] + } + + supportsInterface( + runtime: Runtime, + interfaceId: `0x${string}`, + ): boolean { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'supportsInterface' as const, + args: [interfaceId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: UniflowABI, + functionName: 'supportsInterface' as const, + data: bytesToHex(result.data), + }) as boolean + } + + writeReportFromAllowlistDestanationChainForCCIP( + runtime: Runtime, + selector: bigint, + enable: boolean, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'allowlistDestanationChainForCCIP' as const, + args: [selector, enable], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromOnReport( + runtime: Runtime, + metadata: `0x${string}`, + report: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'onReport' as const, + args: [metadata, report], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedAuthor( + runtime: Runtime, + author: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'setExpectedAuthor' as const, + args: [author], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedWorkflowId( + runtime: Runtime, + id: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'setExpectedWorkflowId' as const, + args: [id], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedWorkflowName( + runtime: Runtime, + name: string, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'setExpectedWorkflowName' as const, + args: [name], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetForwarderAddress( + runtime: Runtime, + forwarder: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'setForwarderAddress' as const, + args: [forwarder], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetupToken( + runtime: Runtime, + token: `0x${string}`, + tokenConfig: { receiver: `0x${string}`; minAmountToTrigger: bigint }, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'setupToken' as const, + args: [token, tokenConfig], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromTransferOwnership( + runtime: Runtime, + newOwner: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'transferOwnership' as const, + args: [newOwner], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromUpdateReceiver( + runtime: Runtime, + token: `0x${string}`, + receiver: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: UniflowABI, + functionName: 'updateReceiver' as const, + args: [token, receiver], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReport( + runtime: Runtime, + callData: Hex, + gasConfig?: { gasLimit?: string }, + ) { + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + /** + * Creates a log trigger for AcrossBridgeInitiated events. + * The returned trigger's adapt method decodes the raw log into AcrossBridgeInitiatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerAcrossBridgeInitiated( + filters?: AcrossBridgeInitiatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'AcrossBridgeInitiated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'AcrossBridgeInitiated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'AcrossBridgeInitiated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeAcrossBridgeInitiated(rawOutput), + } + } + + /** + * Decodes a log into AcrossBridgeInitiated data, preserving all log metadata. + */ + decodeAcrossBridgeInitiated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as AcrossBridgeInitiatedDecoded } + } + + /** + * Creates a log trigger for CCIPBridgeInitiated events. + * The returned trigger's adapt method decodes the raw log into CCIPBridgeInitiatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerCCIPBridgeInitiated( + filters?: CCIPBridgeInitiatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'CCIPBridgeInitiated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'CCIPBridgeInitiated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'CCIPBridgeInitiated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeCCIPBridgeInitiated(rawOutput), + } + } + + /** + * Decodes a log into CCIPBridgeInitiated data, preserving all log metadata. + */ + decodeCCIPBridgeInitiated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as CCIPBridgeInitiatedDecoded } + } + + /** + * Creates a log trigger for ExpectedAuthorUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedAuthorUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedAuthorUpdated( + filters?: ExpectedAuthorUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedAuthorUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousAuthor: f.previousAuthor, + newAuthor: f.newAuthor, + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedAuthorUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousAuthor: f.previousAuthor, + newAuthor: f.newAuthor, + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedAuthorUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedAuthorUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedAuthorUpdated data, preserving all log metadata. + */ + decodeExpectedAuthorUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedAuthorUpdatedDecoded } + } + + /** + * Creates a log trigger for ExpectedWorkflowIdUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedWorkflowIdUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedWorkflowIdUpdated( + filters?: ExpectedWorkflowIdUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousId: f.previousId, + newId: f.newId, + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousId: f.previousId, + newId: f.newId, + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedWorkflowIdUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedWorkflowIdUpdated data, preserving all log metadata. + */ + decodeExpectedWorkflowIdUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedWorkflowIdUpdatedDecoded } + } + + /** + * Creates a log trigger for ExpectedWorkflowNameUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedWorkflowNameUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedWorkflowNameUpdated( + filters?: ExpectedWorkflowNameUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousName: f.previousName, + newName: f.newName, + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousName: f.previousName, + newName: f.newName, + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedWorkflowNameUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedWorkflowNameUpdated data, preserving all log metadata. + */ + decodeExpectedWorkflowNameUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedWorkflowNameUpdatedDecoded } + } + + /** + * Creates a log trigger for ForwarderAddressUpdated events. + * The returned trigger's adapt method decodes the raw log into ForwarderAddressUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerForwarderAddressUpdated( + filters?: ForwarderAddressUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ForwarderAddressUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousForwarder: f.previousForwarder, + newForwarder: f.newForwarder, + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'ForwarderAddressUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousForwarder: f.previousForwarder, + newForwarder: f.newForwarder, + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'ForwarderAddressUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeForwarderAddressUpdated(rawOutput), + } + } + + /** + * Decodes a log into ForwarderAddressUpdated data, preserving all log metadata. + */ + decodeForwarderAddressUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ForwarderAddressUpdatedDecoded } + } + + /** + * Creates a log trigger for OwnershipTransferred events. + * The returned trigger's adapt method decodes the raw log into OwnershipTransferredDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerOwnershipTransferred( + filters?: OwnershipTransferredTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'OwnershipTransferred' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousOwner: f.previousOwner, + newOwner: f.newOwner, + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'OwnershipTransferred' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousOwner: f.previousOwner, + newOwner: f.newOwner, + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'OwnershipTransferred' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeOwnershipTransferred(rawOutput), + } + } + + /** + * Decodes a log into OwnershipTransferred data, preserving all log metadata. + */ + decodeOwnershipTransferred(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as OwnershipTransferredDecoded } + } + + /** + * Creates a log trigger for SecurityWarning events. + * The returned trigger's adapt method decodes the raw log into SecurityWarningDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerSecurityWarning( + filters?: SecurityWarningTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'SecurityWarning' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'SecurityWarning' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'SecurityWarning' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeSecurityWarning(rawOutput), + } + } + + /** + * Decodes a log into SecurityWarning data, preserving all log metadata. + */ + decodeSecurityWarning(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as SecurityWarningDecoded } + } + + /** + * Creates a log trigger for UniflowApproval events. + * The returned trigger's adapt method decodes the raw log into UniflowApprovalDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerUniflowApproval( + filters?: UniflowApprovalTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'UniflowApproval' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + } + const encoded = encodeEventTopics({ + abi: UniflowABI, + eventName: 'UniflowApproval' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + } + return encodeEventTopics({ + abi: UniflowABI, + eventName: 'UniflowApproval' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeUniflowApproval(rawOutput), + } + } + + /** + * Decodes a log into UniflowApproval data, preserving all log metadata. + */ + decodeUniflowApproval(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: UniflowABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as UniflowApprovalDecoded } + } +} + diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow_mock.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow_mock.ts new file mode 100644 index 00000000..62e2b4b3 --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/Uniflow_mock.ts @@ -0,0 +1,23 @@ +// Code generated — DO NOT EDIT. +import type { Address } from 'viem' +import { addContractMock, type ContractMock, type EvmMock } from '@chainlink/cre-sdk/test' + +import { UniflowABI } from './Uniflow' + +export type UniflowMock = { + getExpectedAuthor?: () => `0x${string}` + getExpectedWorkflowId?: () => `0x${string}` + getExpectedWorkflowName?: () => `0x${string}` + getForwarderAddress?: () => `0x${string}` + owner?: () => `0x${string}` + sAllowlistedChains?: (arg0: bigint) => boolean + sCcipRouter?: () => `0x${string}` + sLinkToken?: () => `0x${string}` + sTokenConfig?: (token: `0x${string}`) => readonly [`0x${string}`, bigint] + supportsInterface?: (interfaceId: `0x${string}`) => boolean +} & Pick, 'writeReport'> + +export function newUniflowMock(address: Address, evmMock: EvmMock): UniflowMock { + return addContractMock(evmMock, { address, abi: UniflowABI }) as UniflowMock +} + diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/index.ts b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/index.ts new file mode 100644 index 00000000..4d9b4a6a --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/evm/generated/index.ts @@ -0,0 +1,3 @@ +// Code generated — DO NOT EDIT. +export * from './Uniflow' +export * from './Uniflow_mock' 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-lock.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package-lock.json new file mode 100644 index 00000000..8b44f5ab --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package-lock.json @@ -0,0 +1,369 @@ +{ + "name": "typescript-simple-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "typescript-simple-template", + "version": "1.0.0", + "hasInstallScript": true, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.0.1", + "fs": "^0.0.1-security" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "license": "MIT" + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.6.3", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.6.3", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.6.3", + "@bufbuild/protoplugin": "2.6.3" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.6.3" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.6.3", + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.6.3", + "@typescript/vfs": "^1.5.2", + "typescript": "5.4.5" + } + }, + "node_modules/@chainlink/cre-sdk": { + "version": "1.1.2", + "license": "BUSL-1.1", + "dependencies": { + "@bufbuild/protobuf": "2.6.3", + "@bufbuild/protoc-gen-es": "2.6.3", + "@chainlink/cre-sdk-javy-plugin": "1.1.1", + "@standard-schema/spec": "1.0.0", + "viem": "2.34.0", + "zod": "3.25.76" + }, + "bin": { + "cre-compile": "bin/cre-compile.ts" + }, + "engines": { + "bun": ">=1.2.21" + } + }, + "node_modules/@chainlink/cre-sdk-javy-plugin": { + "version": "1.1.1", + "license": "BUSL-1.1", + "bin": { + "cre-compile-workflow": "bin/compile-workflow.ts", + "cre-setup": "bin/setup.ts" + }, + "engines": { + "bun": ">=1.2.21" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.6", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/base": { + "version": "1.2.6", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.2.21", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.2.21" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/abitype": { + "version": "1.0.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3 >=3.22.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/bun-types": { + "version": "1.2.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "peerDependencies": { + "@types/react": "^19" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "license": "MIT" + }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "license": "ISC" + }, + "node_modules/isows": { + "version": "1.0.7", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/ox": { + "version": "0.8.7", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.0.8", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.4.5", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/viem": { + "version": "2.34.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.6", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.0.8", + "isows": "1.0.7", + "ox": "0.8.7", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.18.3", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} 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..e2293d4c --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/my-workflow/package.json @@ -0,0 +1,16 @@ +{ + "name": "typescript-simple-template", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bunx cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.0.1" + }, + "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/package-lock.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/package-lock.json new file mode 100644 index 00000000..5fffd79e --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "uniflow", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@types/node": "^25.3.0" + } + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/package.json b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/package.json new file mode 100644 index 00000000..57a5004b --- /dev/null +++ b/starter-templates/cross-chain-token-aggregator/cross-chain-token-aggregator-ts/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "@types/node": "^25.3.0" + } +} 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