diff --git a/.github/workflows/bridge-pause-safe.yml b/.github/workflows/bridge-pause-safe.yml new file mode 100644 index 00000000..d885c27d --- /dev/null +++ b/.github/workflows/bridge-pause-safe.yml @@ -0,0 +1,117 @@ +name: Bridge Pause/Unpause via Safe Multisig + +on: + workflow_dispatch: + inputs: + operation: + description: 'Pause operation to perform' + required: true + type: choice + options: + - pause-bridge + - unpause-bridge + - pause-outbound + - unpause-outbound + network: + description: 'Network to perform operation on' + required: true + type: choice + options: + - ethereum + - arbitrum + - sepolia + - arbitrum_sepolia + default: sepolia + dry-run: + description: 'Dry run mode (only prepare and display transaction, do not propose to Safe)' + required: false + type: boolean + default: true + +jobs: + prepare-transaction-calldata: + runs-on: ubuntu-latest + environment: ${{ inputs.network }} + outputs: + transaction-data: ${{ steps.prepare.outputs.transaction-data }} + safe-address: ${{ steps.prepare.outputs.safe-address }} + bridge-address: ${{ steps.prepare.outputs.bridge-address }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + cache: true + + - name: Prepare transaction calldata + id: prepare + env: + CHAIN: ${{ inputs.network }} + run: | + # Get bridge address from config + BRIDGE_ADDRESS=$(jq -r ".chains.${CHAIN}.iexecLayerZeroBridgeAddress" config/config.json) + echo "bridge-address=$BRIDGE_ADDRESS" >> $GITHUB_OUTPUT + + # Determine the function selector and name based on operation + case "${{ inputs.operation }}" in + "pause-bridge") + TRANSACTION_DATA=$(cast calldata "pause()") + FUNCTION_NAME="pause()" + ;; + "unpause-bridge") + TRANSACTION_DATA=$(cast calldata "unpause()") + FUNCTION_NAME="unpause()" + ;; + "pause-outbound") + TRANSACTION_DATA=$(cast calldata "pauseOutboundTransfers()") + FUNCTION_NAME="pauseOutboundTransfers()" + ;; + "unpause-outbound") + TRANSACTION_DATA=$(cast calldata "unpauseOutboundTransfers()") + FUNCTION_NAME="unpauseOutboundTransfers()" + ;; + esac + + echo "transaction-data=$TRANSACTION_DATA" >> $GITHUB_OUTPUT + echo "safe-address=${{ vars.SAFE_ADDRESS }}" >> $GITHUB_OUTPUT + + # Display transaction details + echo "==========================================" + echo "Transaction Details" + echo "==========================================" + echo "Workflow Configuration:" + echo " • Network: ${{ inputs.network }}" + echo " • Operation: ${{ inputs.operation }}" + echo " • Function: $FUNCTION_NAME" + echo " • Safe Address: ${{ vars.SAFE_ADDRESS }}" + echo " • Dry Run: ${{ inputs.dry-run }}" + echo "" + echo "Transaction Details:" + echo " • Target: $BRIDGE_ADDRESS" + echo " • Value: 0 ETH" + echo " • Data: $TRANSACTION_DATA" + echo "" + + if [ "${{ inputs.dry-run }}" == "true" ]; then + echo "✅ DRY RUN MODE: Transaction prepared successfully" + fi + + propose-to-safe-tx: + needs: prepare-transaction-calldata + uses: ./.github/workflows/propose-safe-transaction.yml + secrets: + rpc-url: ${{ secrets.RPC_URL }} + safe-proposer-private-key: ${{ secrets.SAFE_PROPOSER_PRIVATE_KEY }} + safe-api-key: ${{ secrets.SAFE_API_KEY }} + with: + network: ${{ inputs.network }} + safe-address: ${{ needs.prepare-transaction-calldata.outputs.safe-address }} + transaction-to: ${{ needs.prepare-transaction-calldata.outputs.bridge-address }} + transaction-data: ${{ needs.prepare-transaction-calldata.outputs.transaction-data }} + dry-run: ${{ inputs.dry-run }} diff --git a/.github/workflows/manage-contract-roles-safe.yml b/.github/workflows/manage-contract-roles-safe.yml new file mode 100644 index 00000000..0f000955 --- /dev/null +++ b/.github/workflows/manage-contract-roles-safe.yml @@ -0,0 +1,186 @@ +name: Manage Contract Roles via Safe Multisig + +on: + workflow_dispatch: + inputs: + operation: + description: 'Role operation to perform' + required: true + type: choice + options: + - grant + - revoke + role: + description: 'Role to grant or revoke' + required: true + type: choice + options: + - TOKEN_BRIDGE_ROLE + - PAUSER_ROLE + - UPGRADER_ROLE + default: TOKEN_BRIDGE_ROLE + network: + description: 'Network to perform operation on' + required: true + type: choice + options: + - ethereum + - arbitrum + - sepolia + - arbitrum_sepolia + default: sepolia + target_address: + description: 'Address to grant/revoke role to/from' + required: true + type: string + dry-run: + description: 'Dry run mode (only prepare and display transaction, do not propose to Safe)' + required: false + type: boolean + default: true + +jobs: + prepare-transaction-calldata: + runs-on: ubuntu-latest + environment: ${{ inputs.network }} + outputs: + transactions: ${{ steps.prepare.outputs.transactions }} + safe-address: ${{ steps.prepare.outputs.safe-address }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: stable + cache: true + + - name: Prepare transaction calldata + id: prepare + env: + CHAIN: ${{ inputs.network }} + ROLE_NAME: ${{ inputs.role }} + run: | + # Determine which contract to use based on role and network config + APPROVAL_REQUIRED=$(jq -r ".chains.${CHAIN}.approvalRequired" config/config.json) + BRIDGE_ADDRESS=$(jq -r ".chains.${CHAIN}.iexecLayerZeroBridgeAddress" config/config.json) + + if [ "$APPROVAL_REQUIRED" = "true" ]; then + TOKEN_CONTRACT=$(jq -r ".chains.${CHAIN}.rlcLiquidityUnifierAddress" config/config.json) + else + TOKEN_CONTRACT=$(jq -r ".chains.${CHAIN}.rlcCrosschainTokenAddress" config/config.json) + fi + + # Calculate role hash: keccak256("ROLE_NAME") + ROLE_HASH=$(cast keccak "$ROLE_NAME") + + # Determine the function selector and encode calldata + case "${{ inputs.operation }}" in + grant) + TRANSACTION_DATA=$(cast calldata "grantRole(bytes32,address)" "$ROLE_HASH" "${{ inputs.target_address }}") + FUNCTION_NAME="grantRole(bytes32,address)" + ;; + revoke) + TRANSACTION_DATA=$(cast calldata "revokeRole(bytes32,address)" "$ROLE_HASH" "${{ inputs.target_address }}") + FUNCTION_NAME="revokeRole(bytes32,address)" + ;; + *) + echo "❌ Error: Unknown operation ${{ inputs.operation }}" + exit 1 + ;; + esac + + # Prepare transactions array based on role type + TRANSACTIONS='[]' + + case "$ROLE_NAME" in + TOKEN_BRIDGE_ROLE) + # TOKEN_BRIDGE_ROLE is only on token contract (RLCLiquidityUnifier or RLCCrosschainToken) + TRANSACTIONS=$(echo "$TRANSACTIONS" | jq -c --arg to "$TOKEN_CONTRACT" --arg data "$TRANSACTION_DATA" \ + '. += [{"to": $to, "data": $data, "contract": "token"}]') + ;; + PAUSER_ROLE) + # PAUSER_ROLE is only on bridge contract + TRANSACTIONS=$(echo "$TRANSACTIONS" | jq -c --arg to "$BRIDGE_ADDRESS" --arg data "$TRANSACTION_DATA" \ + '. += [{"to": $to, "data": $data, "contract": "bridge"}]') + ;; + UPGRADER_ROLE) + # UPGRADER_ROLE is on both bridge and token contracts + # Add transaction for token contract + TRANSACTIONS=$(echo "$TRANSACTIONS" | jq -c --arg to "$TOKEN_CONTRACT" --arg data "$TRANSACTION_DATA" \ + '. += [{"to": $to, "data": $data, "contract": "token"}]') + # Add transaction for bridge contract + TRANSACTIONS=$(echo "$TRANSACTIONS" | jq -c --arg to "$BRIDGE_ADDRESS" --arg data "$TRANSACTION_DATA" \ + '. += [{"to": $to, "data": $data, "contract": "bridge"}]') + ;; + *) + echo "❌ Error: Unknown role $ROLE_NAME" + '. += [{"to": $to, "data": $data, "contract": "bridge"}]') + ;; + esac + + echo "transactions=$(echo $TRANSACTIONS | jq -c .)" >> $GITHUB_OUTPUT + echo "safe-address=${{ vars.SAFE_ADDRESS }}" >> $GITHUB_OUTPUT + + # Display transaction details for dry-run or verification + echo "==========================================" + echo "Transaction Details" + echo "==========================================" + echo "Workflow Configuration:" + echo " • Network: ${{ inputs.network }}" + echo " • Role: $ROLE_NAME" + echo " • Operation: ${{ inputs.operation }}" + echo " • Function: $FUNCTION_NAME" + echo " • Safe Address: ${{ vars.SAFE_ADDRESS }}" + echo " • Dry Run: ${{ inputs.dry-run }}" + echo "" + echo "────────────────────────────────────────────────────────────────────────────────" + echo "" + + # Display each transaction + TX_COUNT=$(echo $TRANSACTIONS | jq 'length') + for i in $(seq 0 $(($TX_COUNT - 1))); do + TX=$(echo $TRANSACTIONS | jq -r ".[$i]") + TX_TO=$(echo $TX | jq -r '.to') + TX_DATA=$(echo $TX | jq -r '.data') + TX_CONTRACT=$(echo $TX | jq -r '.contract') + + echo "Transaction #$((i + 1)) - ${TX_CONTRACT^} Contract:" + echo " • Target: $TX_TO" + echo " • Address: ${{ inputs.target_address }}" + echo " • Role Name: $ROLE_NAME" + echo " • Role Hash: $ROLE_HASH" + echo " • Value: 0 ETH" + echo " • Data: $TX_DATA" + echo "" + done + + echo "────────────────────────────────────────────────────────────────────────────────" + echo "" + + if [ "${{ inputs.dry-run }}" == "true" ]; then + echo "✅ DRY RUN MODE: Transaction(s) prepared successfully" + echo "ℹ️ These transaction(s) would be proposed to Safe multisig" + echo "ℹ️ Re-run with dry-run=false to actually propose to Safe" + fi + + propose-to-safe-tx: + needs: prepare-transaction-calldata + strategy: + matrix: + transaction: ${{ fromJson(needs.prepare-transaction-calldata.outputs.transactions) }} + uses: ./.github/workflows/propose-safe-transaction.yml + secrets: + rpc-url: ${{ secrets.RPC_URL }} + safe-proposer-private-key: ${{ secrets.SAFE_PROPOSER_PRIVATE_KEY }} + safe-api-key: ${{ secrets.SAFE_API_KEY }} + with: + network: ${{ inputs.network }} + safe-address: ${{ needs.prepare-transaction-calldata.outputs.safe-address }} + transaction-to: ${{ matrix.transaction.to }} + transaction-data: ${{ matrix.transaction.data }} + dry-run: ${{ inputs.dry-run }} diff --git a/.github/workflows/propose-safe-transaction.yml b/.github/workflows/propose-safe-transaction.yml new file mode 100644 index 00000000..f56ed1a4 --- /dev/null +++ b/.github/workflows/propose-safe-transaction.yml @@ -0,0 +1,78 @@ +name: Propose Safe Transaction (Reusable) + +on: + workflow_call: + inputs: + network: + description: 'Network environment' + required: true + type: string + safe-address: + description: 'Safe multisig address' + required: true + type: string + transaction-to: + description: 'Transaction target address' + required: true + type: string + transaction-data: + description: 'Transaction calldata' + required: true + type: string + transaction-value: + description: 'Transaction value in wei' + required: false + type: string + default: "0" + dry-run: + description: 'Dry run mode' + required: false + type: boolean + default: true + secrets: + rpc-url: + description: 'RPC URL for the network' + required: true + safe-proposer-private-key: + description: 'Private key of the Safe proposer' + required: true + safe-api-key: + description: 'Safe API key' + required: true + +jobs: + propose-transaction: + runs-on: ubuntu-latest + environment: ${{ inputs.network }} + steps: + - name: Checkout Safe proposal repository + uses: actions/checkout@v4 + with: + repository: iExecBlockchainComputing/github-actions-workflows + path: .github-actions + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + working-directory: .github-actions/propose-safe-multisig-tx + run: npm ci + + - name: Build the action + working-directory: .github-actions/propose-safe-multisig-tx + run: npm run build + + - name: Propose transaction to Safe + working-directory: .github-actions/propose-safe-multisig-tx + env: + SAFE_ADDRESS: ${{ inputs.safe-address }} + TRANSACTION_TO: ${{ inputs.transaction-to }} + TRANSACTION_VALUE: ${{ inputs.transaction-value }} + TRANSACTION_DATA: ${{ inputs.transaction-data }} + RPC_URL: ${{ secrets.rpc-url }} + SAFE_PROPOSER_PRIVATE_KEY: ${{ secrets.safe-proposer-private-key }} + SAFE_API_KEY: ${{ secrets.safe-api-key }} + DRY_RUN: ${{ inputs.dry-run }} + run: npm run propose diff --git a/README.md b/README.md index 898a39e6..89334101 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,24 @@ The scripts automatically calculate these fees and include them in the transacti Note that production GitHub environments `arbitrum` and `ethereum` can only be used with the `main` branch. +## Safe Multisig Integration + +All critical administrative operations are secured using Safe (Gnosis Safe) multisig wallets. This ensures that important actions like contract upgrades, role management, and pause operations require approval from multiple authorized signers. + +### Supported Operations + +- **Pause/Unpause**: Control bridge operations with different pause levels +- **Role Management**: Grant or revoke TOKEN_BRIDGE_ROLE +- **Admin Transfer**: Transfer admin role to new addresses + +### GitHub Actions Workflows + +- `.github/workflows/bridge-pause-safe.yml` - Propose pause/unpause transactions +- `.github/workflows/manage-contract-roles-safe.yml` - Propose role management transactions +- `.github/workflows/transfer-admin-role-safe.yml` - Propose admin role transfer transactions + +All workflows use the reusable Safe multisig workflow from [iExecBlockchainComputing/github-actions-workflows](https://github.com/iExecBlockchainComputing/github-actions-workflows). + ## TODO - Use an enterprise RPC URL for `secrets.SEPOLIA_RPC_URL` in Github environment `ci`.