Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/signing-gate-dry-run.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
66 changes: 56 additions & 10 deletions agent/execute.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,56 @@
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...");

const deployment = loadDeployment();

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,
Expand All @@ -34,7 +63,6 @@ async function run() {
audit("withdrawal_checked", request);

const approval = createApprovalRequest(request);

const autoExecute = process.env.AUTO_EXECUTE === "true";

if (!autoExecute) {
Expand All @@ -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
};
}

Expand Down
26 changes: 23 additions & 3 deletions signing-gate/policyEngine.js
Original file line number Diff line number Diff line change
@@ -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 };
138 changes: 137 additions & 1 deletion signing-gate/signerAdapter.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { debug } = require("../agent/debug-log");

function createSignerRequest(payload) {
return {
type: "SIGN_TRANSACTION",
Expand All @@ -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
};
Loading