diff --git a/.env.example b/.env.example index d7e2eb776..a3d5e38c6 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,18 @@ BASE_RPC_URL=https://mainnet.base.org MAINNET_RPC_URL= +BASE_CHAIN_ID=8453 +# Approved withdrawal route BASE_EXECUTION_CONTRACT= +ALLOWED_CONTRACT=0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 +ALLOWED_RECIPIENT=0xfd1610f5eae31dd757e55d6b4ba543b80a2720b3 +ALLOWED_FUNCTION=withdrawTo +MAX_WITHDRAW_WEI=1000000000000000000 + +# Coinbase Smart Wallet runtime hooks +COINBASE_WALLET_CLIENT_MODE=coinbase-smart-wallet +WITHDRAW_CALLDATA= # NEVER commit this value. Store as GitHub Secret: PRIVATE_KEY PRIVATE_KEY= @@ -12,5 +22,7 @@ ETHERSCAN_API_KEY= BASESCAN_API_KEY= TREASURY_ADDRESS= -DESTINATION_ADDRESS= +DESTINATION_ADDRESS=0xfd1610f5eae31dd757e55d6b4ba543b80a2720b3 ALLOW_WITHDRAWALS=false +DEBUG_LOGS=true +AUTO_EXECUTE=false diff --git a/.github/workflows/signing-gate-dry-run.yml b/.github/workflows/signing-gate-dry-run.yml index bbe8a96b0..c5a9015a9 100644 --- a/.github/workflows/signing-gate-dry-run.yml +++ b/.github/workflows/signing-gate-dry-run.yml @@ -1,11 +1,13 @@ name: Signing Gate Dry Run on: + workflow_dispatch: pull_request: push: branches: - main - feature/signing-gate-agent + - feature/wallet-interface jobs: dry-run: diff --git a/agent/execute.js b/agent/execute.js index a44ee51f1..7e213fe1a 100644 --- a/agent/execute.js +++ b/agent/execute.js @@ -1,14 +1,40 @@ require("dotenv").config(); +const { Interface } = require("ethers"); const validateTransaction = require("./transaction-guard"); const loadDeployment = require("./deployment-loader"); const audit = require("./audit-log"); const { debug } = require("./debug-log"); -const { enforcePolicy } = require("../signing-gate/policyEngine"); +const { enforcePolicy, BASE_CHAIN_ID } = require("../signing-gate/policyEngine"); const { createApprovalRequest, approve } = require("../signing-gate/approvalQueue"); -const { createSignerRequest } = require("../signing-gate/signerAdapter"); +const { + createSignerRequest, + createCoinbaseSmartWalletSigner, + submitApprovedWithdrawal +} = require("../signing-gate/signerAdapter"); + +const WITHDRAW_INTERFACE = new Interface([ + "function withdrawTo(address recipient, uint256 amount)" +]); + +function resolveWalletClient(explicitClient) { + return ( + explicitClient || + globalThis.coinbaseSmartWalletClient || + globalThis.walletClient || + globalThis.ethereum || + null + ); +} + +function buildWithdrawalCalldata(request) { + return WITHDRAW_INTERFACE.encodeFunctionData("withdrawTo", [ + request.recipient, + BigInt(request.amountWei) + ]); +} -async function run() { +async function run(options = {}) { debug("agent_started"); console.log("Starting Base execution agent..."); @@ -16,12 +42,15 @@ async function run() { const request = { network: process.env.NETWORK || "base", + chainId: Number(process.env.BASE_CHAIN_ID || BASE_CHAIN_ID), contractAddress: deployment.contractAddress || deployment.address, recipient: process.env.DESTINATION_ADDRESS, - amount: process.env.WITHDRAW_AMOUNT_WEI + amount: process.env.WITHDRAW_AMOUNT_WEI, + amountWei: process.env.WITHDRAW_AMOUNT_WEI, + functionName: process.env.ALLOWED_FUNCTION || "withdrawTo" }; - debug("transaction_request_created", request); + debug("withdrawal_request_created", request); validateTransaction({ amount: request.amount, @@ -34,7 +63,6 @@ async function run() { audit("withdrawal_checked", request); const approval = createApprovalRequest(request); - const autoExecute = process.env.AUTO_EXECUTE === "true"; if (!autoExecute) { @@ -45,14 +73,32 @@ async function run() { } const approved = approve(request); - const signerRequest = createSignerRequest(approved); + const walletClient = resolveWalletClient(options.walletClient); + const signer = createCoinbaseSmartWalletSigner(walletClient, { + chainId: request.chainId, + account: options.account + }); + + const signerRequest = createSignerRequest({ + ...approved, + calldata: options.calldata || buildWithdrawalCalldata(request) + }); - audit("withdrawal_ready_for_executor", signerRequest); debug("signer_request_created", signerRequest); + audit("withdrawal_ready_for_executor", signerRequest); + + const execution = await submitApprovedWithdrawal(signer, { + ...request, + calldata: signerRequest.payload.calldata, + value: "0x0" + }); + + audit("withdrawal_submitted", execution); return { - status: "ready_for_protected_executor", - signerRequest + status: execution.status === "confirmed" ? "submitted_and_confirmed" : "submitted", + signerRequest, + execution }; } diff --git a/signing-gate/policyEngine.js b/signing-gate/policyEngine.js index 0b3e843b6..99defa524 100644 --- a/signing-gate/policyEngine.js +++ b/signing-gate/policyEngine.js @@ -1,19 +1,39 @@ const DEFAULT_LIMIT = BigInt(process.env.MAX_WITHDRAW_WEI || "1000000000000000000"); +const BASE_CHAIN_ID = Number(process.env.BASE_CHAIN_ID || 8453); +const ALLOWED_CONTRACT = (process.env.ALLOWED_CONTRACT || "").toLowerCase(); +const ALLOWED_RECIPIENT = (process.env.ALLOWED_RECIPIENT || "").toLowerCase(); +const ALLOWED_FUNCTION = (process.env.ALLOWED_FUNCTION || "withdrawTo").toLowerCase(); + +function normalizeAddress(value) { + return (value || "").toLowerCase(); +} function enforcePolicy(request) { - if (!request.network || request.network !== (process.env.ALLOWED_NETWORK || "base")) { + if (request.network && request.network !== (process.env.ALLOWED_NETWORK || "base")) { throw new Error("Network not allowed"); } + if (request.chainId && Number(request.chainId) !== BASE_CHAIN_ID) { + throw new Error("Chain ID not allowed"); + } + if (request.amount && BigInt(request.amount) > DEFAULT_LIMIT) { throw new Error("Amount exceeds signing policy limit"); } - if (process.env.ALLOWED_CONTRACT && request.contractAddress?.toLowerCase() !== process.env.ALLOWED_CONTRACT.toLowerCase()) { + if (ALLOWED_CONTRACT && normalizeAddress(request.contractAddress) !== ALLOWED_CONTRACT) { throw new Error("Contract not approved"); } + if (ALLOWED_RECIPIENT && normalizeAddress(request.recipient) !== ALLOWED_RECIPIENT) { + throw new Error("Recipient not approved"); + } + + if (request.functionName && normalizeAddress(request.functionName) !== ALLOWED_FUNCTION) { + throw new Error("Withdrawal function not approved"); + } + return true; } -module.exports = { enforcePolicy }; +module.exports = { enforcePolicy, BASE_CHAIN_ID }; diff --git a/signing-gate/signerAdapter.js b/signing-gate/signerAdapter.js index 9a2eff901..dd87dd3db 100644 --- a/signing-gate/signerAdapter.js +++ b/signing-gate/signerAdapter.js @@ -1,3 +1,5 @@ +const { debug } = require("../agent/debug-log"); + function createSignerRequest(payload) { return { type: "SIGN_TRANSACTION", @@ -6,4 +8,138 @@ function createSignerRequest(payload) { }; } -module.exports = { createSignerRequest }; +function resolveWalletClient(explicitClient) { + return ( + explicitClient || + globalThis.coinbaseSmartWalletClient || + globalThis.walletClient || + globalThis.ethereum || + null + ); +} + +function createCoinbaseSmartWalletSigner(walletClient, options = {}) { + const client = resolveWalletClient(walletClient); + + if (!client) { + throw new Error("Coinbase Smart Wallet client is required for live execution"); + } + + const chainId = Number(options.chainId || process.env.BASE_CHAIN_ID || 8453); + const account = options.account || client.account?.address || client.selectedAddress || null; + + async function sendTransaction(transaction) { + if (typeof client.request === "function") { + const walletSendCalls = { + method: "wallet_sendCalls", + params: [ + { + chainId: `0x${chainId.toString(16)}`, + from: account || undefined, + calls: [ + { + to: transaction.to, + data: transaction.data || "0x", + value: transaction.value || "0x0" + } + ] + } + ] + }; + + try { + const callId = await client.request(walletSendCalls); + debug("coinbase_wallet_send_calls_submitted", { callId, chainId, account }); + return callId; + } catch (walletSendCallsError) { + const ethSendTransaction = { + method: "eth_sendTransaction", + params: [ + { + chainId: `0x${chainId.toString(16)}`, + from: account || undefined, + to: transaction.to, + data: transaction.data || "0x", + value: transaction.value || "0x0" + } + ] + }; + + const txHash = await client.request(ethSendTransaction); + debug("coinbase_eth_send_transaction_submitted", { txHash, chainId, account }); + return txHash; + } + } + + if (typeof client.sendTransaction === "function") { + const txHash = await client.sendTransaction({ + chainId, + from: account || undefined, + to: transaction.to, + data: transaction.data || "0x", + value: transaction.value || "0x0" + }); + debug("coinbase_send_transaction_submitted", { txHash, chainId, account }); + return txHash; + } + + throw new Error("Unsupported Coinbase Smart Wallet client"); + } + + async function getReceipt(txHash) { + if (!txHash || typeof client.request !== "function") { + return null; + } + + try { + return await client.request({ + method: "eth_getTransactionReceipt", + params: [txHash] + }); + } catch (error) { + debug("coinbase_receipt_lookup_failed", { message: error.message }); + return null; + } + } + + return { + provider: "coinbase-smart-wallet", + chainId, + account, + sendTransaction, + getReceipt + }; +} + +async function submitApprovedWithdrawal(signer, request) { + const transaction = { + to: request.contractAddress, + data: request.calldata || "0x", + value: request.value || "0x0" + }; + + debug("coinbase_withdrawal_submitting", { + contractAddress: request.contractAddress, + recipient: request.recipient, + amount: request.amount, + chainId: request.chainId + }); + + const txHash = await signer.sendTransaction(transaction); + const receipt = typeof signer.getReceipt === "function" ? await signer.getReceipt(txHash) : null; + + const result = { + txHash, + receipt, + status: receipt && receipt.status ? "confirmed" : "submitted" + }; + + debug("coinbase_withdrawal_result", result); + return result; +} + +module.exports = { + createSignerRequest, + createCoinbaseSmartWalletSigner, + submitApprovedWithdrawal +};